从 Bean 到 Web 请求:Spring 核心运行机制、AOP、事件与常见陷阱
Spring 好用的地方,恰恰也是它最容易让人掉坑的地方:大量重复、繁琐的对象创建、依赖装配、生命周期管理、代理增强和 Web 请求分发工作都被框架接管了。开发者只需要写几个注解,程序往往就能运行;但一旦包结构、Bean 数量、代理方式、初始化时机或监听体系发生变化,那些平时看不见的约定就会突然暴露出来。
理解 Spring,不能只停留在“这个注解怎么用”。更有效的方式,是建立一条完整的运行链路:Bean 如何被发现、如何被创建、如何寻找依赖、如何完成初始化、何时变成代理对象、事件如何广播,以及一个 HTTP 请求最终如何找到 Controller 方法。
本文围绕这条主线,把 Bean 定义、依赖注入、生命周期、AOP、Spring 事件和 Spring MVC 请求处理串成一套完整的知识体系。
文中涉及的部分源码类名、调用栈和示例来自 Spring 5.x / Spring Boot 2.x 时代的实现。理解这些源码的目的,是建立运行模型,而不是把内部实现细节当成永远不变的 API 契约。尤其是 AOP Advice 的内部排序等细节,工程代码应尽量通过显式配置表达意图,不要依赖某个版本的偶然实现。
1. Spring 到底替我们做了什么
传统 Java 代码中,一个对象依赖另一个对象时,最直观的写法是自己创建:
1 | |
这种方式的问题不在于 new 本身,而在于对象创建、依赖关系和业务逻辑耦合在了一起。一旦 ComponentA 的构造方式变化,所有创建它的地方都可能跟着变化;如果还要处理单例、测试替身、统一日志、事务、权限、监控等横切能力,代码会越来越难维护。
Spring 的核心思路可以简化成一个“对象中介”:
- 扫描或读取配置,确定哪些对象由容器管理;
- 创建这些对象,也就是创建 Bean;
- 根据 Bean 之间的依赖关系完成装配;
- 在初始化阶段执行必要的扩展逻辑;
- 如果对象需要 AOP,则返回代理对象;
- 在运行期间提供 Bean 查找、事件广播、Web 请求分发等能力;
- 容器关闭时执行需要的销毁逻辑。
可以把它抽象成下面的流程:
flowchart LR
A[扫描配置与注解] --> B[生成 BeanDefinition]
B --> C[实例化 Bean]
C --> D[依赖注入 populateBean]
D --> E[初始化 initializeBean]
E --> F{需要 AOP?}
F -- 否 --> G[暴露原始 Bean]
F -- 是 --> H[创建 Proxy]
H --> I[暴露代理 Bean]
G --> J[运行期使用]
I --> J
J --> K[事件 / Web / 业务调用]
K --> L[容器关闭]
L --> M[销毁回调]
真正值得记住的不是每个内部方法,而是这几个阶段之间的先后关系。
2. Bean 是整个 Spring 运行模型的起点
Spring Boot、Spring MVC、Spring AOP 乃至更上层的 Spring Cloud,本质上都建立在 Bean 容器之上。一个 Controller 能不能处理请求,一个 Service 能不能被注入,一个切面能不能生效,第一步都要先回答:这个类有没有成为 Bean,以及拿到的到底是不是预期的 Bean。
2.1 @SpringBootApplication 默认扫描哪里
一个最常见的 Spring Boot 程序如下:
1 | |
@SpringBootApplication 组合了多个注解,其中包含组件扫描能力。关键点在于:当没有显式指定扫描包时,扫描范围以启动类所在包为基础向下扫描。
因此下面的结构通常没有问题:
1 | |
但如果把 Controller 移到启动类扫描范围之外:
1 | |
即使 Controller 代码一行都没改,也可能因为没有被组件扫描发现而无法注册成 Bean。
更稳妥的做法有两种。
显式指定扫描范围:
1 | |
或者使用 @ComponentScan:
1 | |
需要注意一个容易被忽略的点:一旦自己显式配置扫描范围,就要确认原来默认能够扫描到的包没有被漏掉。 修复“新包扫描不到”的同时又把旧包排除,是很典型的二次故障。
工程上最简单的规避方式,是让启动类位于项目业务包的根包位置,例如:
1 | |
这样大多数场景甚至不需要额外配置扫描范围。
2.2 BeanDefinition、实例和 Bean 不是一回事
组件扫描发现类以后,Spring 首先得到的不是业务对象实例,而是描述这个 Bean 的元数据,也就是 BeanDefinition。之后才根据这些元数据完成实例化、属性填充、初始化和代理增强。
这个区分非常重要,因为很多问题其实发生在不同阶段:
- “扫描不到”发生在 BeanDefinition 发现阶段;
- “构造器参数找不到”发生在实例化阶段;
@Autowired字段为空发生在依赖填充阶段之前;@PostConstruct属于初始化阶段;- AOP Proxy 通常在初始化后由 BeanPostProcessor 包装出来;
- 销毁方法则在容器关闭时执行。
把这些阶段混成一个“Spring 创建对象”的黑盒,就很容易对执行时机产生错误预期。
3. Bean 创建的核心链路:实例化、填充、初始化
Spring 创建 Bean 时,有一条非常关键的主线:
1 | |
可以把它理解为:
sequenceDiagram
participant BF as BeanFactory
participant Bean as Bean 实例
participant BPP as BeanPostProcessor
BF->>BF: createBeanInstance()
BF->>Bean: 调用构造器创建对象
BF->>BF: populateBean()
BF->>BPP: 解析 @Autowired / @Value 等
BPP->>Bean: 设置依赖字段/方法
BF->>BF: initializeBean()
BF->>BPP: BeforeInitialization
BPP->>Bean: @PostConstruct 等
BF->>Bean: InitializingBean.afterPropertiesSet()
BF->>BPP: AfterInitialization
BPP-->>BF: 可能返回 AOP Proxy
这条顺序解释了大量常见问题。
4. 构造器依赖:Spring 会自动去容器里找参数
假设一个 Bean 只有下面这个构造器:
1 | |
对于普通 Java,我们会自然地想到:调用时自己传一个字符串即可。但对于由 Spring 创建的 Bean,构造器参数本身就是依赖。Spring 需要为 String serviceName 找到一个可装配的 Bean。
如果容器中没有符合要求的 String Bean,创建 ServiceImpl 就可能失败。可以显式提供:
1 | |
Spring 内部最终会把构造器参数包装成依赖描述,再通过类似 resolveDependency(...) 的流程寻找候选 Bean。
这里有一个非常重要的边界:“找不到依赖一定报错”并不总成立。
例如构造参数是集合:
1 | |
如果没有任何 String Bean,Spring 在某些构造器装配场景中会对数组、集合、Map 做 fallback,传入空容器,而不是直接失败。
所以看到“依赖不存在”时,不要只凭直觉判断结果,要先确认:
- 依赖是不是必选;
- 目标是不是数组、集合或 Map;
- 是否存在 fallback;
- 当前走的是字段注入、构造器注入还是工厂方法参数解析。
5. 为什么更推荐构造器注入
一个很经典的错误是:在构造器里使用字段注入的对象。
1 | |
原因并不神秘:构造器执行发生在 createBeanInstance(),字段注入发生在后面的 populateBean()。构造器执行时,lightService 还没来得及被注入。
改成构造器注入即可让依赖在对象创建时就存在:
1 | |
这也是构造器注入在工程实践中更可靠的原因之一:
- 必需依赖在对象构造完成时就已经满足;
- 字段可以声明为
final; - 更容易测试;
- 不会出现“构造器执行时依赖还没填充”的时间差。
如果某段逻辑确实要求在依赖注入完成之后执行,可以使用生命周期回调。
1 | |
也可以实现 InitializingBean:
1 | |
两者都发生在依赖填充之后,但业务代码通常应优先考虑更低耦合的生命周期表达方式,不必为了初始化动作主动绑定大量 Spring 专有接口。
6. @Autowired 到底怎样决定注入谁
字段注入可以抽象成两步:
- 找出需要注入的字段或方法;
- 根据依赖描述寻找候选 Bean,并通过反射把结果写进去。
负责处理 @Autowired 的关键扩展之一是 AutowiredAnnotationBeanPostProcessor。它会在 Bean 填充阶段找到带有注入注解的成员,然后调用 BeanFactory 去解析依赖。
6.1 一个接口有多个实现时为什么会报错
假设:
1 | |
同时存在两个实现:
1 | |
而使用方只有:
1 | |
Spring 根据类型能够找到两个候选 Bean,却必须给单值字段选出一个,典型结果就是:
1 | |
候选决策可以理解成类似下面的优先级判断:
- 是否存在明确的
@Primary; - 是否有优先级信息;
- Bean 名是否与依赖名称精确匹配;
- 如果依然无法唯一确定,则失败。
例如给默认实现加 @Primary:
1 | |
或者显式使用 @Qualifier:
1 | |
如果业务本来就需要全部实现,则不要强行“选一个”,直接注入集合更加自然:
1 | |
这里的核心思维不是“背哪个注解能消除报错”,而是先问清楚业务语义:到底需要一个默认实现、一个指定实现,还是全部实现。
7. Bean 名称并不是永远“类名首字母小写”
显式按名称引用 Bean 时,一个不起眼的命名规则就可能导致 NoSuchBeanDefinitionException。
对于没有显式指定名称的注解 Bean,Spring 会通过 AnnotationBeanNameGenerator 生成默认 Bean 名。其中会使用类似 Introspector.decapitalize(...) 的规则。
普通类:
1 | |
默认名通常是:
1 | |
但如果类名开头连续两个字符都是大写,例如:
1 | |
decapitalize 不会简单地把第一个字符变小写,默认名可能仍然保持:
1 | |
因此下面这种写法可能匹配不到:
1 | |
如果 Bean 名是重要契约,最稳妥的方式是直接显式命名:
1 | |
然后引用方统一使用这个名字:
1 | |
7.1 内部类的默认 Bean 名更容易让人意外
假设在 StudentController 中定义内部类:
1 | |
经过内部类短类名处理后,默认 Bean 名并不只是简单的:
1 | |
在课程案例对应的命名规则下,得到的是带外部类信息的名字:
1 | |
因此更推荐直接给内部 Bean 指定稳定名称,而不是让调用方依赖内部命名算法。
8. @Value 不只是“从 application.properties 取字符串”
@Value 常被当成配置文件注入工具,但它的能力远不止如此。
注入普通字符串:
1 | |
读取占位符:
1 | |
读取其他 Bean 属性或表达式:
1 | |
甚至可以得到 Bean:
1 | |
其工作流程可以概括为:
flowchart LR
A[发现 @Value] --> B[取得注解字符串]
B --> C[解析占位符/表达式]
C --> D[得到值或对象]
D --> E[类型转换]
E --> F[写入目标字段]
8.1 配置同名冲突:${username} 为什么不是配置文件里的值
假设配置文件中有:
1 | |
代码:
1 | |
最终值未必来自 application.properties。Spring 的属性解析是针对一组有顺序的 PropertySource 进行查找,系统环境变量、JVM 系统属性、配置文件等都可能参与解析。
如果系统环境中已经存在同名 USERNAME / username,就可能先命中系统级配置。
即便改成:
1 | |
也未必安全,因为 JVM 自身就存在 user.name 这样的系统属性。
因此配置项应该使用业务命名空间前缀,例如:
1 | |
而不是使用过于通用的:
1 | |
对于大量同类配置,工程上还应优先考虑结构化配置绑定,而不是让大量分散的 @Value 充斥业务类。
9. 集合注入:收集所有 Bean 与直接注入一个集合 Bean 是两回事
Spring 可以自动收集某个类型的所有 Bean:
1 | |
然后:
1 | |
Spring 会解析 List<Student> 的泛型元素类型 Student,寻找所有 Student Bean,再组装成目标 List。
但另一种写法是直接定义一个 List<Student> Bean:
1 | |
这两种语义不同:
- 收集式:找出容器里所有
StudentBean; - 直接式:把容器中的某个
List<Student>Bean 当成整体依赖。
课程案例对应实现里,解析多元素依赖会优先尝试收集式;只要能收集到元素,就会直接返回,不再继续用后面的“直接 List Bean”覆盖结果。
因此不要让两种方式混在同一个依赖点上,然后期待 Spring 自动合并。
9.1 集合顺序如何控制
如果业务依赖集合执行顺序,应显式提供顺序信息。例如:
1 | |
也可以让对象实现 Ordered / PriorityOrdered 等排序契约。原则很简单:顺序是业务语义时,就应该把顺序写成显式契约,而不是依赖 Bean 扫描或声明的偶然顺序。
10. Prototype 为什么注入到 Singleton 后“不再变化”
下面这个 Bean 被定义为 Prototype:
1 | |
然后注入单例 Controller:
1 | |
很多人会直觉认为:既然 ServiceImpl 是 Prototype,每次 HTTP 请求应该得到新对象。
实际上不是。
HelloWorldController 默认是 Singleton,创建 Controller 时,字段注入只执行一次。当时容器为了满足依赖创建了一个 Prototype ServiceImpl,随后这个引用就被写进 Controller 字段。以后请求访问的是同一个 Controller,自然也一直拿着同一个字段引用。
Prototype 的语义更准确地说是:每次向容器请求该 Bean 时创建新实例,而不是“任何地方每次使用这个 Java 字段时都自动创建新实例”。
10.1 需要每次拿新对象怎么办
方法一:每次主动从容器取:
1 | |
方法二:使用 @Lookup:
1 | |
@Lookup 方法的代码体并不是关键。Spring 会为类生成方法覆盖,真正调用时通过 BeanFactory 获取目标 Bean。其本质是 Method Injection(方法注入)。
方法三:使用 Scoped Proxy:
1 | |
此时注入的是代理对象,真正使用时再向容器获取对应作用域中的目标实例。
不同方式各有取舍。业务代码如果大量直接依赖 ApplicationContext#getBean(),会把容器 API 侵入业务逻辑;能通过合理的对象边界解决时,优先从设计上降低作用域混用。
11. Bean 生命周期:为什么 @Bean 可能意外调用 shutdown()
Bean 的销毁阶段还有一个非常隐蔽的约定。
假设:
1 | |
如果这个类通过组件扫描注册:
1 | |
仅仅因为存在一个叫 shutdown 的方法,并不意味着 Spring 一定会在容器关闭时调用它。
但如果改成:
1 | |
课程案例对应的 @Bean 默认销毁策略会尝试推断销毁方法。在没有显式配置时,Spring 会检查 close 或 shutdown 这类约定方法,并可能把它注册为销毁回调。
于是一个本来只是业务含义的 shutdown(),可能在容器关闭时被自动执行。
如果明确不希望自动推断:
1 | |
另一方面,如果 Bean 实现了 AutoCloseable / Closeable,则其 close() 本来就是明确的资源释放契约:
1 | |
这种方式比“碰巧有一个名为 shutdown 的业务方法”更加清晰。
12. BeanPostProcessor:Spring 扩展能力的关键插槽
前面的 @Autowired、@PostConstruct、AOP 其实都不断出现同一个角色:BeanPostProcessor。
Spring 并不是把所有行为硬编码进 BeanFactory,而是在 Bean 生命周期不同阶段调用一组后置处理器,从而让不同功能以插件方式参与 Bean 创建。
典型角色包括:
AutowiredAnnotationBeanPostProcessor:处理依赖注入;CommonAnnotationBeanPostProcessor/InitDestroyAnnotationBeanPostProcessor:处理生命周期注解;AnnotationAwareAspectJAutoProxyCreator:判断 Bean 是否需要 AOP,并在合适阶段包装成代理对象。
因此看 Spring 源码时,一个很高效的思路是:
当某个注解看起来“神奇地生效”时,先找是谁扫描它,再看对应扩展是在 Bean 生命周期哪个阶段被调用。
13. Spring AOP 的本质:把 Bean 换成代理对象
AOP(Aspect Oriented Programming,面向切面编程)解决的是横切逻辑与业务逻辑的解耦问题,例如:
- 日志;
- 性能统计;
- 监控;
- 权限校验;
- 异常处理;
- 统一认证。
Spring AOP 的核心不是“修改了原对象的方法”,而是:当容器认为某个 Bean 需要增强时,向调用方暴露一个 Proxy。调用 Proxy 方法时,先执行拦截器链,再进入真正目标对象。
flowchart LR
Caller[调用方] --> Proxy[Spring Proxy]
Proxy --> Advice1[Advice / Interceptor]
Advice1 --> Target[Target Bean]
Target --> Advice2[后置逻辑]
Advice2 --> Caller
13.1 JDK 动态代理与 CGLIB
Spring AOP 常见两类代理方式:
| 方式 | 核心思路 | 典型限制 |
|---|---|---|
| JDK Dynamic Proxy | 基于接口创建代理 | 主要围绕接口类型工作 |
| CGLIB Proxy | 生成目标类子类并覆盖可代理方法 | 受类/方法可继承、可覆盖等条件影响 |
Spring Boot 项目一般通过 AOP Starter 引入相关能力:
1 | |
代理创建通常发生在 Bean 初始化的后置处理阶段。AnnotationAwareAspectJAutoProxyCreator 会判断一个 Bean 是否命中 Advisor,如果需要,则通过 ProxyFactory 创建代理并替换原始 Bean 的对外暴露对象。
14. 为什么 this.xxx() 不会触发 Spring AOP
这是 Spring AOP 最经典的边界问题之一。
1 | |
切面拦截 pay():
1 | |
从外部拿到的 ElectricService 是代理对象:
1 | |
但是进入目标对象的 charge() 后,this 指向目标对象自身:
1 | |
这一步没有重新经过 Proxy,因此 pay() 不会进入 AOP 拦截器链。
这就是 self-invocation(自调用)绕过代理。
14.1 如何解决
最优先的方案通常是重新划分对象职责,让需要增强的方法通过另一个 Bean 的边界调用:
1 | |
如果确实需要当前代理,可以暴露 Proxy:
1 | |
然后:
1 | |
课程材料还讨论了“自己注入自己”的方式。这个方法在一些环境下会受到循环依赖策略影响,也会让对象依赖关系变得晦涩,因此不应作为首选设计。
核心原则只有一句:想让 Spring AOP 生效,调用必须穿过 Spring Proxy 边界。
15. 为什么给类加了 AOP 后,直接读 public 字段反而可能空指针
下面的代码看起来很普通:
1 | |
业务代码直接访问:
1 | |
没有 AOP 时可能运行正常。但当 AdminUserService 因为某个切面变成 CGLIB Proxy 后,课程案例展示了一个非常反直觉的现象:代理实例上的字段可能没有经过目标类正常的成员初始化,直接读取代理字段时得到 null。
其原因与代理实例的创建方式有关。Spring 的 CGLIB 代理在课程对应版本中会优先尝试使用 Objenesis,而 Objenesis 可以绕开普通构造过程创建对象。其底层可能借助序列化式反射构造能力,这种实例化路径和正常调用构造器并不相同。
因此,不要把代理对象理解成“目标对象完整复制了一份,只是方法前后加点代码”。
更稳妥的写法是遵守封装边界,通过方法获取状态:
1 | |
调用:
1 | |
代理方法调用会被拦截器接管,再委托给真正的 Target 对象;Target 的成员字段已经正常初始化,因此能够得到正确结果。
这也给出一个很实际的编码原则:不要把 Spring Bean 的 public 字段当作稳定调用接口,尤其当这个 Bean 未来可能被 AOP、事务、缓存、权限等代理机制包装。
16. AOP Advice 的执行顺序:不要依赖“代码写在前面”
当同一个目标方法命中多个增强时,顺序就会成为业务问题。
例如权限校验耗时 1 秒:
1 | |
同时又用 @Around 统计 charge() 耗时。如果 Around 把 Before 包在里面,那么统计结果自然会包含鉴权时间。
课程源码对应版本中,同一个 Aspect 内部会先对 Advice 方法进行排序,其中不同 Advice 类型存在内部顺序,同类型时还可能继续按方法名比较。这能解释某些“明明方法写在后面却先执行”的现象。
但工程上不应该把内部方法名排序当成业务契约。更清晰的方式是:
- 把职责不同的增强拆成不同 Aspect;
- 使用
@Order显式表达切面优先级。
1 | |
1 | |
显式顺序的好处不只是“当前能跑对”,更重要的是代码读者能直接理解设计意图,也减少对框架内部版本细节的依赖。
17. Spring 事件:本质上是监听器模式
Spring Event 可以抽象成三个角色:
- Event:事件对象;
- Multicaster:事件广播器;
- Listener:监听器。
flowchart LR
Publisher[事件发布方] --> Event[Event]
Event --> M[ApplicationEventMulticaster]
M --> L1[Listener A]
M --> L2[Listener B]
M --> L3[Listener C]
最经典的监听器接口是:
1 | |
这套模型看起来简单,但常见错误集中在三个问题上:
- 这个事件到底有没有被发布;
- 发布事件和监听器是不是处于同一个广播体系;
- 一个监听器失败会不会影响其他监听器。
18. 一个 Event 类存在,不代表启动时一定会发布它
ContextStartedEvent 是 Spring 存在的事件,但 Spring Boot 常规启动并不等价于调用 ApplicationContext#start()。
课程案例里,ContextStartedEvent 的发布发生在:
1 | |
而 Spring Boot 启动主线更关注的是创建 Context、准备 Context、Refresh Context。Refresh 完成时会发布的是 ContextRefreshedEvent。
因此监听启动完成类事件时,第一步不是“看名字像不像”,而是查清楚:到底哪段代码发布了它,这段代码在当前启动路径上会不会执行。
如果确实需要处理 ContextStartedEvent,就必须真的调用 start();如果只是想处理容器 Refresh 完成,则应该监听与 Refresh 生命周期对应的事件。
事件排查的第一个公式:
1 | |
19. Spring Boot 启动早期事件与普通容器事件可能不是同一套监听体系
ApplicationEnvironmentPreparedEvent 发生得很早,环境已经准备,但 ApplicationContext 还没有完成普通 Bean 创建。
课程案例对应的早期事件由启动阶段的 EventPublishingRunListener 和它持有的 initialMulticaster 广播。这个广播器的监听器来源与 Context 完成之后的普通 applicationEventMulticaster 不完全相同。
这意味着:仅仅把一个监听器写成 @Component,并不保证它在容器尚未扫描创建该 Bean 之前就能接收到早期事件。
课程给出的思路包括:
- 在构建
SpringApplication时显式添加 Listener; - 使用当时 Spring Boot 的工厂加载机制,把监听器放进启动阶段能够预先加载的配置体系。
关键不是死记 spring.factories 文件,而是理解时间轴:
1 | |
所以处理框架启动早期事件时,一定要确认 Listener 的注册时间早于 Event 发布时间。
20. Spring 事件默认是同步的:一个监听器抛异常可能阻塞后续监听器
假设有两个监听器:
1 | |
1 | |
SimpleApplicationEventMulticaster 的核心结构可以简化成:
1 | |
没有 Executor 时,事件发布线程会顺序调用 Listener。如果第一个 Listener 的异常直接向外传播,后面的 Listener 就没有机会执行。
解决思路之一,是监听器自己隔离异常;更统一的方式,是给 Multicaster 设置 ErrorHandler。
例如课程中使用:
1 | |
这样单个监听器失败不会直接打断整个广播链路。
20.1 异步事件
如果希望 Listener 不阻塞发布线程,可以给 Multicaster 设置线程池:
1 | |
此时事件发布线程和处理线程分离。
不过异步化不是免费的:一旦跨线程,原线程上的调用上下文、错误传播方式、执行顺序等都会发生变化。因此“想快一点”不是唯一判断标准,应该先明确事件是否允许异步、是否要求顺序、是否要求发布者感知处理失败。
21. 从 Spring Core 走向 Spring Web:一个 HTTP 请求到底发生了什么
Spring MVC 的 Controller 看起来极其简单:
1 | |
背后至少需要完成三件事:
- 底层服务器解析 HTTP 请求;
- 根据 Path、HTTP Method 等信息找到 Java Handler;
- 解析参数并反射调用目标方法。
如果把整个流程展开,可以得到:
sequenceDiagram
participant Client as Client
participant Server as Tomcat/Jetty
participant Filter as Servlet Filter Chain
participant DS as DispatcherServlet
participant HM as HandlerMapping
participant HA as HandlerAdapter
participant C as Controller
Client->>Server: HTTP Request
Server->>Server: NIO 接收与协议解析
Server->>Filter: 进入工作线程
Filter->>DS: Servlet service()
DS->>HM: getHandler(request)
HM-->>DS: HandlerExecutionChain
DS->>HA: getHandlerAdapter(handler)
HA->>C: 解析参数并调用方法
C-->>HA: Return Value
HA-->>DS: ModelAndView / Response
DS-->>Filter: 写回响应
Filter-->>Server: Response
Server-->>Client: HTTP Response
21.1 通信层不是 Spring 自己完成的
Spring MVC 本身并不直接承担底层 Socket 通信。Spring Boot Web 应用通常通过内嵌 Servlet 容器提供通信能力,例如 Tomcat 或 Jetty。
课程示例中,spring-boot-starter-web 默认间接引入 Tomcat。若要切换 Jetty,可以排除 Tomcat 再引入 Jetty:
1 | |
Tomcat 收到连接后,通过 NIO Endpoint 监听事件,再把请求交给工作线程,随后进入 Servlet Filter Chain,最终到达 DispatcherServlet。
22. DispatcherServlet:Spring MVC 的中央调度器
DispatcherServlet 本质上是一个 Servlet。一个请求进入 Spring MVC 后,核心逻辑可以简化成:
1 | |
这里有两个核心问题。
22.1 怎么找到 Controller 方法
HandlerMapping 负责根据当前请求寻找 Handler。对于注解式 Controller,关键组件之一是 RequestMappingHandlerMapping。
它内部维护的映射可以抽象成:
1 | |
这和自己实现一个 Map<RequestKey, Method> 的思路本质相同,只是 Spring MVC 的匹配维度和规则复杂得多。
22.2 找到方法后怎么执行
匹配到 Controller 方法后,需要解析方法参数,再进行反射调用。课程源码链路最终进入类似 InvocableHandlerMethod#doInvoke 的位置,通过反射调用目标方法。
因此一个 Controller 请求的本质仍然可以压缩成:
1 | |
23. RequestMappingHandlerMapping 的映射是什么时候建立的
请求到达时如果再扫描所有 Controller,性能显然无法接受,所以映射关系是在应用启动时预先构建的。
RequestMappingHandlerMapping 初始化完成后,会扫描候选 Bean,判断它们是不是 Handler,再解析方法级映射。
课程对应实现中,判断 Handler 的逻辑大体类似:
1 | |
如果是 Handler,则继续检测方法并注册映射。
这也把 Spring Core 与 Spring MVC 串起来了:
1 | |
所以最开始那个“把 Controller 移出扫描包后接口 404”的问题,本质上不仅仅是组件扫描问题,它直接破坏了后续整个 MVC Handler 注册链。
24. 一张图串起 Spring Core、AOP、事件和 Web
前面的知识点看似分散,其实都围绕 Bean 容器形成一条非常清晰的链路:
flowchart TD
A[SpringApplication.run] --> B[创建/刷新 ApplicationContext]
B --> C[扫描组件]
C --> D[注册 BeanDefinition]
D --> E[实例化 Bean]
E --> F[解析构造器依赖]
F --> G[populateBean 字段/方法注入]
G --> H[initializeBean 生命周期回调]
H --> I{命中 AOP Advisor?}
I -- 是 --> J[创建 Proxy Bean]
I -- 否 --> K[暴露原始 Bean]
J --> L[容器可用]
K --> L
L --> M[RequestMappingHandlerMapping 扫描 Controller]
L --> N[ApplicationEvent Listener 参与事件系统]
M --> O[HTTP Request -> DispatcherServlet]
O --> P[HandlerMapping -> HandlerAdapter]
P --> Q[Controller / Service 调用]
Q --> R[AOP Proxy 拦截横切逻辑]
只要这张图在脑子里,很多问题都能迅速归类。
25. 常见故障应该怎样定位
25.1 Bean 根本不存在
典型现象:
1 | |
优先排查:
- 包是否在组件扫描范围;
- 注解是否能让它成为组件;
@Bean配置类有没有被加载;- 条件配置是否成立;
- Bean 名是否与
@Qualifier一致。
25.2 Bean 太多,Spring 不知道选谁
典型现象:
1 | |
优先排查:
- 业务到底应该选一个还是全部;
- 是否需要
@Primary; - 是否应该使用
@Qualifier; - 是否可以改成
List<T>/Map<String, T>。
25.3 构造器里字段为空
优先想到生命周期顺序:
1 | |
如果构造阶段就需要依赖,使用构造器注入。
25.4 加了切面却不生效
优先问:调用有没有穿过 Proxy?
最典型的错误就是:
1 | |
如果是同类自调用,Spring Proxy 根本没有参与第二次调用。
25.5 加 AOP 后对象行为突然变了
优先区分:
- 当前引用是目标对象还是代理对象;
- 直接访问的是字段还是方法;
- CGLIB/JDK Proxy 的类型边界是什么;
- 对象是不是通过正常构造器创建。
25.6 事件监听器不执行
按照固定顺序检查:
1 | |
25.7 Controller 接口失效
沿着 MVC 注册链反查:
1 | |
26. 阅读 Spring 源码时最值得记住的入口
不需要一开始就通读 Spring 源码。围绕问题找到“关键入口”,效率更高。
| 问题 | 关键入口/角色 | 主要关注点 |
|---|---|---|
| 组件扫描 | ComponentScanAnnotationParser、扫描器 |
扫描哪些包 |
| Bean 名生成 | AnnotationBeanNameGenerator |
默认 Bean 名如何产生 |
| Bean 创建 | AbstractAutowireCapableBeanFactory#doCreateBean |
生命周期总入口 |
| 构造器实例化 | createBeanInstance、ConstructorResolver |
构造器选择、参数解析 |
| 字段依赖注入 | AutowiredAnnotationBeanPostProcessor |
注入点发现与执行 |
| 依赖选择 | DefaultListableBeanFactory#doResolveDependency |
候选 Bean、优先级、多元素依赖 |
@Value |
PropertySourcesPropertyResolver 等 |
PropertySource 查找与转换 |
| 初始化回调 | initializeBean |
@PostConstruct、InitializingBean |
| 销毁逻辑 | DisposableBeanAdapter |
close/shutdown/DisposableBean |
| AOP 代理 | AnnotationAwareAspectJAutoProxyCreator |
何时需要代理 |
| CGLIB Proxy | CglibAopProxy |
代理实例和方法拦截 |
| Spring Event | SimpleApplicationEventMulticaster |
Listener 获取、同步/异步、异常处理 |
| MVC 调度 | DispatcherServlet#doDispatch |
Handler 查找与调用 |
| MVC 映射 | RequestMappingHandlerMapping |
Controller 映射注册 |
| Controller 调用 | InvocableHandlerMethod#doInvoke |
反射调用 HandlerMethod |
源码学习真正有效的方法不是记住调用栈,而是建立三个问题:
- 这个功能在哪个生命周期阶段发生?
- 谁负责扫描、决策或代理?
- 最终对象、依赖或 Handler 是怎么被选出来的?
27. 工程实践中的一组稳定原则
把所有案例压缩成工程习惯,可以得到下面这组原则。
1. 启动类放在业务根包
尽量利用 Spring Boot 默认组件扫描规则,避免包移动后 Bean 静默消失。
2. 必需依赖优先构造器注入
让对象完成构造时就处于可用状态,同时减少字段注入时序问题。
3. 多实现依赖显式表达选择策略
默认实现用 @Primary,指定实现用 @Qualifier,全部实现使用集合,不要靠“碰巧只剩一个”。
4. Bean 名如果重要就显式命名
不要让业务契约依赖 decapitalize、内部类命名等细节。
5. 配置项使用业务前缀
避免 ${username}、${name} 这类高冲突键名与系统环境、JVM 属性发生覆盖。
6. 不要混用两套集合注入语义
“收集所有 T Bean”和“注入一个 List
7. Singleton 持有 Prototype 时明确获取策略
字段注入不会自动变成“每次调用创建新对象”。需要新实例时用 Provider、Lookup、Scoped Proxy 或重新设计对象边界。
8. 初始化和销毁逻辑使用明确生命周期契约
不要在构造器里假设字段注入已完成,也不要让一个普通业务方法因为名字恰好叫 shutdown 而承担意外生命周期语义。
9. AOP 只作用于经过 Proxy 的调用
同类 this 自调用不会进入代理。相比自注入和 AopContext,优先考虑拆分职责。
10. 不直接依赖代理 Bean 的公开字段
把状态封装在方法后面,避免代理实例化差异破坏业务代码。
11. 切面顺序要显式
职责不同的 Advice 拆成不同 Aspect,用 @Order 表达顺序,不依赖方法名或内部排序。
12. 事件处理必须先确认发布链路
Event 存在不代表一定发布;Listener 是 Bean 也不代表在早期事件发生前已经注册。
13. 默认同步事件要隔离异常
一个 Listener 的异常不应该无意中让后续 Listener 全部失效;需要时配置 ErrorHandler 或异步 Executor。
14. 排障时从生命周期和边界入手
Spring 中大量“玄学问题”最终都可以还原为:扫描边界、对象生命周期、依赖决策、代理边界、线程边界和请求映射边界。
28. 总结:把 Spring 看成一条对象流水线
如果只记住各种注解,Spring 会显得像一个庞大的魔法系统;如果把它还原成对象流水线,很多问题就变得非常朴素。
Spring 首先扫描配置生成 BeanDefinition,然后实例化对象,根据构造器和注入点寻找依赖,再通过 BeanPostProcessor 完成注入与生命周期扩展;需要 AOP 的对象会被包装成 Proxy,运行期间事件通过 Multicaster 分发,Web 请求则由 Servlet 容器接收后进入 DispatcherServlet,再通过 HandlerMapping 找到 Controller 方法并反射调用。
于是大量常见问题都能找到明确位置:
- Controller 移包后失效,是扫描边界;
- 一个接口两个实现注入失败,是依赖决策;
- 构造器里字段为空,是生命周期顺序;
- Prototype 被固定,是作用域与注入时机;
this调用切面失效,是代理边界;- 加 AOP 后直接读字段异常,是代理实例与 Target 的区别;
- Event 监听不到,是发布时机或广播体系;
- Controller 方法为什么能被调用,是启动期建立 Handler 映射、运行期 DispatcherServlet 查表并反射执行。
真正掌握 Spring,不是把源码背下来,而是遇到故障时能够快速判断:当前问题发生在对象被发现之前、创建过程中、依赖解析阶段、初始化阶段、代理之后,还是进入 Web / Event 运行链之后。
当这套边界感建立起来之后,Spring 就不再是“突然罢工的保姆”,而是一套可以被推理、调试和验证的对象运行系统。