Chapter 01

核心概念:容器、Bean、DI、AOP 词汇

起点给了一句话本质:容器是对象图装配机。这一章把装配机用到的词汇逐个钉死——后两章讲机制时不再停下来解释术语,所以这里的每个定义都要落到地。

本章你将建立的 schema

  • IoC「反转」的到底是哪一项控制权,反转给了谁
  • Bean 是实例、BeanDefinition 是配方——两者分属不同阶段
  • BeanFactory 与 ApplicationContext 的"惰性 vs 急切"分界
  • 三种注入方式背后的"时机差异",这是后面循环依赖的伏笔
  • AOP 六个术语之间的关系,以及"连接点只有方法执行"这一 Spring 特有约束

全章用一个贯穿的场景来钉概念:一个下单服务 OrderService,依赖一个支付网关 PaymentGateway 和一个库存仓储 InventoryRepository。每个概念都回到这个场景,看它在装配这三者时扮演什么角色。

1.1IoC / 控制反转:反转了"获得依赖"的控制权

IoC:对象不再自己创建和查找依赖,而是被动接受外部(容器)塞进来的依赖。

为什么需要它

没有容器时,OrderService 要用支付网关,就得自己 new PaymentGateway(...)。一旦支付网关换实现(从支付宝换成 Stripe)、或它本身又依赖一串配置,OrderService 就被迫知道这些细节。控制权("用哪个实现、怎么造出来")攥在使用者手里,耦合就此产生。

底层机制(比文档深一层):「反转」反转的是获得依赖的控制权——从"对象主动 new 或主动查找"反转成"容器在启动时通过反射造好实例、再把依赖推送进来"。依赖注入(DI)是这个反转的具体落地手段。所以一句被反复问到的面试题——"IoC 和 DI 是不是一回事"——答案是:同一件事的两个视角。IoC 是"控制权反转"这个结果,DI 是"靠注入来实现反转"这个手段。代价也实打实:启动期要反射扫描和实例化、运行期多一层间接、异常栈里会冒出容器和代理的类名。

类比 · 带边界声明

像点外卖 vs 自己买菜做饭:自己做,你要控制采购、火候、摆盘(自己 new 一切);点外卖,你只声明"要一份宫保鸡丁",平台负责备齐食材并送到(容器装配)。类比失效处:外卖每次现做,而容器里的单例 bean 默认只做一次、长期复用——这恰恰是 1.5 节作用域要处理的问题。

场景走查:装配 OrderService 的两种世界

没有容器:控制权在使用者手里Java
// OrderService 被迫知道"怎么造"它的每一个依赖
public class OrderService {
    private final PaymentGateway gateway;
    private final InventoryRepository inventory;

    public OrderService() {
        // 自己 new:换实现要改这里,依赖的依赖也得自己拼
        this.gateway   = new StripeGateway(new HttpClient(), "sk_live_xxx");
        this.inventory = new MySqlInventoryRepository(new DataSource("jdbc:..."));
    }
}
有容器(IoC):只声明依赖,容器来装配Java
@Service
public class OrderService {
    private final PaymentGateway gateway;
    private final InventoryRepository inventory;

    // 只声明"我需要这两样",怎么造、用哪个实现,交给容器
    public OrderService(PaymentGateway gateway, InventoryRepository inventory) {
        this.gateway   = gateway;
        this.inventory = inventory;
    }
}

逐步对照(代码 → 概念):

  • 第一段构造器里的 new StripeGateway(...):OrderService 掌握"获得依赖"的控制权——这是没有反转的世界。
  • 第二段构造器只接收 PaymentGateway 参数、不关心怎么来:控制权已反转给容器。这一步就是 DI(具体说是构造器注入,1.4 节细讲)。
  • @Service 是一个声明:"把这个类登记成一个 bean,纳入容器管理"——下一节讲 bean。
没有容器 OrderService Gateway Inventory new new 控制权向下:自己造依赖 有容器(IoC) IoC 容器 OrderService 注入 Gateway / Inventory 控制权反转:容器推送依赖
图 1.1IoC 反转的是"获得依赖"的控制方向。注意:箭头方向变了——左边 OrderService 指向它造的依赖;右边容器指向 OrderService,依赖是被"推"进去的。

与下一个概念的关系:容器要"把依赖推进来",前提是它得先知道有哪些对象要管、各自怎么造。这份"清单 + 配方"就是 bean 与 BeanDefinition。

1.2Bean 与 BeanDefinition:实例 vs 配方

Bean 是容器管理的对象实例;BeanDefinition 是描述"如何造这个 bean"的元数据配方。

为什么需要它

容器不能凭空造对象。它需要一份结构化描述:这个 bean 是哪个类、单例还是多例、构造参数是什么、要注入哪些属性、初始化和销毁时调用哪个方法。把"描述"和"实例"分成两个阶段,容器才能在造对象之前先修改描述(比如把配置占位符 ${db.url} 替换成真实值)。

底层机制(比文档深一层):BeanDefinition 是一个可被修改的对象,登记在 BeanFactory 内部的一张 map 里(beanDefinitionMap)。容器启动时分两段走:先把所有 BeanDefinition 收齐(解析 @Component 扫描、@Bean 方法、XML),这时一个 bean 实例都还没造;之后才按配方逐个实例化。正因为有这个"配方先就位、实例后创建"的间隙,才有了 02 章的关键扩展点——在实例化前批量改写配方(占位符替换、@Configuration 解析都在这里发生)。

类比 · 带边界声明

BeanDefinition 像菜谱,bean 像照菜谱做出来的那盘菜。同一份菜谱(单例定义)默认只做一盘、大家共享。类比失效处:菜谱是死的,而 BeanDefinition 在开火前还能被改(改食材、改份量)——这正是它和静态配置的本质区别。

与下一个概念的关系:这些 BeanDefinition 收在哪、bean 实例缓存在哪?答案是容器本身。但"容器"有两个层次的接口,分界很重要。

1.3BeanFactory vs ApplicationContext:惰性内核 vs 急切全家桶

BeanFactory 是容器的最小内核(惰性按需造 bean);ApplicationContext 是它的超集,启动时急切造好所有单例并附带企业级能力。

为什么需要它

只有 getBean 这一个能力,框架跑不起来——它还需要发布事件、做国际化、加载资源,以及自动发现并注册那些"扩展点"组件。把最小内核(BeanFactory)和企业级超集(ApplicationContext)分层,既保留了底层灵活性,又让应用代码默认拿到功能完整的那个。

底层机制(比文档深一层):ApplicationContext 在接口上 extends BeanFactory,实现上内部组合了一个 DefaultListableBeanFactory 来做真正的 bean 管理,自己在外面加了事件多播、消息源、资源加载,以及关键的一条——自动探测并注册容器里所有的 BeanPostProcessor 和 BeanFactoryPostProcessor(裸用 BeanFactory 这些得手动注册)。还有一个常被忽略的硬区别:BeanFactory.getBean 是惰性的,用到时才造;而 ApplicationContext 在启动的 refresh() 末尾会急切实例化所有非 lazy 单例。这条决定了一件事——配置错误在启动期就炸,而不是等到第一次请求才炸,这对线上服务是好事。

ApplicationContext(急切 · 全家桶) BeanFactory 内核 getBean(惰性) BeanDefinition map 单例缓存 依赖解析 事件发布 / 监听(events) 国际化 i18n / 资源加载 自动注册 BPP / BFPP 启动末尾急切造所有单例
图 1.2ApplicationContext = BeanFactory 内核 + 企业级外壳。注意:朱红那条"自动注册 BPP/BFPP"——它让后置处理器无需手动登记就生效,正是 02、03 章一切"魔法"能自动发生的前提。

与下一个概念的关系:容器把依赖"推进" bean 的动作,落到代码上有三种写法,它们的差别不只是风格——而是注入发生的时机不同。

1.4依赖注入的三种方式:构造器 / setter / 字段

同一个依赖可以从构造器参数、setter 方法、或直接打在字段上注入——区别在注入发生的时机和带来的约束。

为什么需要它

三种方式不是等价的口味选择。它们决定了:依赖是不是必填、对象能不能做成不可变(final)、不靠容器能不能单元测试、以及——最关键的——循环依赖能不能被容器解开。

底层机制(比文档深一层):时机是分水岭。构造器注入在容器调用构造器创建实例那一刻就必须把依赖准备好——没有依赖,对象根本造不出来。setter / 字段注入发生在"实例已经造出来之后"的属性填充阶段。这个时机差,直接导致 02 章的一个硬结论:构造器注入的循环依赖无解(A 的构造需要 B、B 的构造需要 A,谁都先造不出来),而 setter/字段注入的循环依赖能靠三级缓存绕开。字段注入还有个隐藏代价:依赖被藏在类内部,不 new 不暴露,使得单元测试必须靠反射或容器才能塞进 mock。

三种注入写法(同一个 OrderService)Java
// ① 构造器注入:创建实例时就要依赖;可用 final,依赖必填、可测
@Service
public class OrderService {
    private final PaymentGateway gateway;
    public OrderService(PaymentGateway gateway) { this.gateway = gateway; }
}

// ② setter 注入:实例造好后调用 setter 填入;适合可选 / 可重配的依赖
@Service
public class OrderService {
    private PaymentGateway gateway;
    @Autowired public void setGateway(PaymentGateway g) { this.gateway = g; }
}

// ③ 字段注入:实例造好后用反射直接写字段;最省字,但最难测、最易藏循环依赖
@Service
public class OrderService {
    @Autowired private PaymentGateway gateway;
}
想一想

如果 OrderService 构造器需要 PaymentGateway,而 PaymentGateway 构造器又需要 OrderService(互相依赖),用构造器注入,容器启动时会发生什么?换成字段注入呢?

停 10 秒,再展开

构造器注入:容器造 OrderService 要先有 PaymentGateway,造 PaymentGateway 又要先有 OrderService——死锁,启动期抛 BeanCurrentlyInCreationException。字段注入:两者都能先"半成品"造出来(实例存在、字段暂空),再互相填字段,能解开。

这背后是"实例化"和"属性填充"两个阶段的先后——03 之前的 02 章会用三级缓存把这件事讲透。这里先记住时机差异这根线。

官方与社区现在的默认推荐是构造器注入:依赖必填、对象可做成不可变、不依赖容器就能 new 出来测试,而且循环依赖会在启动期就暴露成错误,而不是潜伏到运行期变成一个诡异的 null。三者的完整权衡对比放在 03 章,那里有判别表。

与下一个概念的关系:注入进来的依赖,是每次都新造一个,还是全局共享一个?这由 bean 的作用域决定。

1.5Bean 作用域:singleton 与 prototype

作用域决定容器为一个 BeanDefinition 造几个实例、活多久:singleton 全局一个,prototype 每次取都新造。

为什么需要它

无状态的服务(OrderService)共享一个实例最省资源;有状态的对象(一个购物车、一次会话)则需要各用各的。作用域就是这个"造几个、谁共享"的策略开关。

底层机制(比文档深一层):singleton(默认)在容器里只造一次,造好缓存在 singletonObjects 这张 map 里,之后每次 getBean 都返回同一个引用,销毁由容器管。prototype 每次 getBean 都走一遍创建流程返回新实例,而且容器造完就撒手——不缓存、不负责调用它的销毁方法。深一层的陷阱在这:把一个 prototype 注入到一个 singleton 的字段里,注入只发生在 singleton 创建的那一次,于是这个 prototype 实际上被"焊死"成了单例,它"每次都新建"的承诺在这种注入下静默失效。web 环境还有 request / session 等作用域,本质是"绑定到某个上下文生命周期"的扩展。

想一想

一个 @Scope("prototype") 的 ShoppingCart 被 @Autowired 进一个单例的 OrderService 字段。请求来了一百次,产生了几个 ShoppingCart 实例?

先答,再展开

只有 1 个。字段注入在 OrderService(单例)创建时发生一次,那一次拿到一个 ShoppingCart 就被长期持有,后面一百次请求复用的都是它。要让每次拿到新的,得用 ObjectProvider<ShoppingCart>、@Lookup 方法或作用域代理——这条在 03 章的失败模式里会再出现。

与下一个概念的关系:以上都是"容器怎么管对象"。还有一类需求是"在不改对象代码的前提下,给一批方法统一加行为(日志、事务、限流)"——这是 AOP 要解决的,它有自己的一套词汇。

1.6AOP 术语:切面、切点、通知、连接点、织入、代理

AOP 把"散落在很多方法里的横切逻辑"(日志、事务)抽出来集中定义,再在运行期织入目标方法周围。

为什么需要它

"每个 service 方法前后打日志、包事务"这类逻辑,如果手写,会散落进几百个方法里,改一处要改几百处。AOP 让这类横切关注点在一个地方定义、统一作用到一批方法上。

六个术语必须一次对齐,后面 03 章直接用:

表 1.1 · AOP 六术语
术语是什么在 Spring 里的具体形态
连接点 Join point程序执行中可以插入行为的点Spring 里只有一种:方法的执行
切点 Pointcut一个谓词,匹配出"哪些连接点"表达式,如 execution(* com..*Service.*(..))
通知 Advice在匹配的连接点上要执行的动作before / after / around / after-returning / after-throwing
切面 Aspect切点 + 通知 的模块化封装一个 @Aspect 类
织入 Weaving把切面接到目标对象上的过程Spring 在运行期用代理织入
目标 / 代理被增强的原对象 / 包了一层的对象JDK 动态代理 或 CGLIB 子类

底层机制(比文档深一层):注意表里加粗的两条 Spring 特有约束。第一,Spring 的连接点只有"方法执行"——它不像完整的 AspectJ 那样能拦字段读写、构造器调用。第二,织入发生在运行期,靠生成一个代理对象完成:容器把通知逻辑包进一个代理,外部对目标方法的调用先经过代理、再转给原对象。这两条合起来推出一个 03 章的核心结论——既然增强只在"从外部经过代理引用"的调用上生效,那么对象内部的 this.method() 自调用走的是原始引用、绕过了代理,通知不触发。@Transactional 在同类自调用下静默失效,根因就在这里。

切面 Aspect 切点 Pointcut(匹配哪些方法) 通知 Advice(做什么) 织入 Weaving 运行期 · 生成代理 代理 Proxy 目标对象 Target 原始 OrderService 外部调用 连接点 = 方法执行 先过代理
图 1.3AOP 六术语的关系链:切面(切点+通知)→ 织入 → 代理包住目标。注意:外部调用必须"先过代理"通知才生效——记住这条,03 章的自调用失效就是它的反面。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 用一句话说清 IoC 和 DI 的关系:它们是两个不同的机制,还是同一件事的两个视角?
  2. BeanDefinition 和 bean 实例分属容器启动的哪两个阶段?这个间隙让容器能做什么?
  3. ApplicationContext 相比裸 BeanFactory,多出的哪一项能力,是后面"注解自动生效"的前提?
  4. 为什么构造器注入的循环依赖无解,而字段注入的能解?答到"时机"层面。
  5. Spring AOP 的"连接点"只有哪一种?这个约束如何引出 this.method() 自调用不被增强?
答案(先做完再展开)
  1. 同一件事的两个视角:IoC 是"获得依赖的控制权从对象反转到容器"这个结果,DI(注入)是实现这个反转的手段。
  2. 第一阶段收齐所有 BeanDefinition(还没造实例),第二阶段按配方造实例。间隙让容器能在实例化前批量改写配方——占位符替换、@Configuration 解析都在此发生。
  3. 自动探测并注册容器里所有 BeanPostProcessor / BeanFactoryPostProcessor。没有它,@Autowired、AOP 这些都不会自动生效。
  4. 构造器注入要在"创建实例"那一刻就拿到依赖,循环时谁都造不出来;字段/setter 注入在"实例已存在"后才填属性,可以先各造半成品再互填。根因是实例化与属性填充的先后时机。
  5. 只有"方法执行"。因为增强靠代理、只在"外部经过代理引用"的调用上生效,对象内部 this.method() 走原始引用绕过代理,通知不触发。
进阶挑战 · 刚好够不着

不看 02 章,先推一下"两阶段"的后果

已知容器"先收齐配方、再造实例"。假设你想写一段逻辑:把所有名字以 Legacy 开头的 bean 的作用域,从 singleton 偷偷改成 prototype。你觉得这段逻辑应该挂在"收齐配方之后、造实例之前",还是"造完实例之后"?为什么?

提示(卡住再展开)

作用域是配方(BeanDefinition)上的属性,一旦实例造出来就晚了。所以要挂在"配方已就位、实例未创建"的那个间隙——这正是 02 章 BeanFactoryPostProcessor 的工作时机。你刚刚自己推出了它存在的理由。