Java微服务(一):1.0 Spring6 - Java开发框架的始祖

Java微服务(一):1.0 Spring6 - Java开发框架的始祖
Prorise1. [核心思想] Spring 启示录:从设计原则到控制反转
摘要: 本章从 OCP 开闭原则 与 DIP 依赖倒置原则 出发,引出 Spring 的核心思想——控制反转 (IoC),为理解其解耦能力奠定理论基石。
1.1. 软件开发的“初心”:OCP 开闭原则
我们进行软件开发时,最核心的目标之一就是构建易于维护和扩展的系统。开闭原则(Open-Closed Principle, OCP)正是指导我们实现这一目标的基础性原则,它要求一个软件实体(如类、模块、函数等)应该对扩展开放,对修改关闭。
在传统的、层级分明的代码结构中,我们常常会遇到一个棘手的问题:高耦合。这意味着代码模块之间紧密地绑定在一起。
从上图可以很明显地看出,上层 是依赖 下层 的。UserController
依赖 UserServiceImpl
,而 UserServiceImpl
又依赖 UserDaoImplForMySQL
。这种依赖关系会导致一个连锁反应:下层模块一旦发生任何改动,上层模块极有可能需要随之修改。这便是所谓的“牵一发而动全身”。
当我们需要为软件增加新功能时,我们应当通过 增加新的代码(例如,新的类)来实现,而不是去修改那些已经被测试过且运行正常的旧代码。修改旧代码的风险在于,它可能会引入未知的缺陷,迫使我们对整个项目进行全方位回归测试,这是一个极其耗时且繁琐的过程。
1.2. 解耦的钥匙:依赖倒置原则 (DIP)
为了破解高耦合的困局,实现开闭原则,我们需要一把关键的“钥匙”——依赖倒置原则 (Dependence Inversion Principle, DIP)。它倡导我们应该 面向抽象(接口)编程,而不是面向具体实现编程。
核心提示:包括开闭原则、依赖倒置原则在内的软件设计七大原则,它们的共同目标都是在为“解耦”服务。
你可能会说,上图中的代码已经遵循了“面向接口编程”的规范。确实,UserService
依赖的是 UserDao
接口。但问题出在对象的创建上:new UserDaoImplForOracle()
这行代码,让我们再次与具体的实现类产生了耦合。
依赖倒置原则的目标是让 上层不再依赖下层,实现依赖关系的“倒置”。完全符合依赖倒置原则的理想代码中,UserService
只持有 UserDao
接口的引用,完全不出现 new
任何具体实现类的代码。
这种理想的代码会带来一个显而易见的问题:userDao
引用是 null
,运行时必然会导致空指针异常。要解决这个异常,我们必须回答两个核心问题:
- 第一个问题:谁来负责对象的创建 ?(谁来执行
new UserDaoImplForOracle()
?) - 第二个问题:谁来负责把创建的对象赋到属性上 ?(谁来把对象赋值给
userDao
属性?)
值得庆幸的是,Spring 框架正是为解决这两个核心问题而生的。它能帮我们创建对象,并自动地将这些对象赋值给需要的属性,建立它们之间的依赖关系。
这种将对象的创建权和对象关系的管理权从我们的业务代码中移交出去的编程范式,就引出了 Spring 的核心思想——控制反转。
1.3. Spring 的灵魂:控制反转 (IoC) 与依赖注入 (DI)
控制反转(Inversion of Control, 缩写为 IoC),是面向对象编程中的一种核心设计思想,其主要目的就是用来 降低代码之间的耦合度。它的核心理念是:将对象的创建权交出去,将对象和对象之间关系的管理权交出去,由一个独立的第三方容器来负责这一切的创建与维护工作。
IoC 是一种现代设计思想,其理念与 GoF 23 种经典设计模式一脉相承,但因其出现较晚而未被收录。
控制反转(IoC)是一种思想,而 依赖注入(Dependency Injection, 缩写为 DI)是实现这种思想最常见、最重要的方式。我们将在后续章节中详细学习,DI 的具体实现又包括两种主要方式:
- Set 方法注入
- 构造方法注入
而 Spring 框架,正是一个完美实现了 IoC 思想,并以 DI 作为其核心机制的顶级容器框架。
2. [宏观视角] Spring 框架概述
摘要: 本章我们将建立对 Spring 框架的整体认知,梳理 Spring 6.x 的核心模块构成,并总结其轻量、非侵入、IoC和AOP等关键特性。
2.1. Spring 简介:为简化开发而生
Spring 是一个由 Rod Johnson 创建的开源 Java 框架,它的诞生是为了解决早期企业级应用(特别是 EJB)开发的复杂性、臃肿以及难以测试等问题。我们可以将 Spring 的核心价值概括为:它是一个轻量级的控制反转(IoC)和面向切面(AOP)的容器框架。
Spring 的最终目标是为我们简化开发,让我们能将精力完全集中在核心业务逻辑的实现上,而尽可能少地关注那些非业务性的代码,如事务控制、安全日志等。从简单性、可测试性和松耦合的角度出发,任何 Java 应用都可以从 Spring 中受益。
2.2. Spring 6.x 核心模块全景图
进入 Spring 6.x 时代,整个框架由大约 8 个模块组成,这些模块按功能被清晰地划分到不同的组中。理解这些模块的构成,有助于我们从宏观上把握 Spring 的能力版图。
我们通过下面这张图,可以对 Spring 框架的核心构成有一个直观的理解。这些模块就像乐高积木,我们可以按需组合,构建出强大的应用程序。
下表清晰地总结了这些核心模块的功能:
模块 (Module) | 核心功能描述 |
---|---|
Spring Core | (框架基石) 实现了 控制反转 (IoC),是所有功能的基础。 |
Spring AOP | (切面编程) 提供完整的 面向切面编程 支持,用于解耦业务与横切关注点。 |
Spring DAO | (数据访问) 对原生 JDBC 进行了抽象和封装,简化了数据访问代码。 |
Spring ORM | (对象关系映射) 提供对 MyBatis 、Hibernate 等主流 ORM 框架的集成支持。 |
Spring Context | (应用上下文) 提供国际化、事件传播、JNDI 等企业级服务,是框架功能的延伸。 |
Spring Web MVC | (Web 框架) Spring 自带的、成熟的 MVC 框架,用于开发传统的 Servlet Web 应用。 |
Spring Webflux | (响应式 Web) 完全异步、非阻塞的 响应式 Web 框架,适用于高并发场景。 |
Spring Web | (集成支持) 用于集成 Struts 等早期的第三方 Web 框架。 |
在现代开发中,我们主要使用 Spring Web MVC
或 Spring Webflux
,而 Spring Web
模块已较少使用
我们上篇系列教程以 Spring Core 为准,对于一些 Springboot 的知识,在 Spring 框架中其实也可以直接使用,所以我们会以对比的形式,大家可以直接学习 SpringBoot 的相关语法,原生 Spring6 涉及到 xml 配置的语法简单过一下即可,开发不会再使用!
2.3. Spring 的核心特性总结
Spring 之所以能在众多框架中脱颖而出,得益于其一系列优雅且强大的核心特性。我们可以将其归纳如下:
核心特性 | 说明 |
---|---|
轻量 | 无论是 JAR 包大小还是运行时资源开销,Spring 都极其轻量,对系统性能影响微乎其微。 |
非侵入式 | 业务代码不依赖于 Spring 的特定 API,可以轻松地在不同环境中复用和测试。 |
控制反转 (IoC) | Spring 的灵魂。通过 IoC 技术促进松耦合,由容器被动地将依赖注入对象,而非对象主动索取。 |
面向切面 (AOP) | 提供对 AOP 的丰富支持,允许我们将业务逻辑与系统服务(如事务、日志)优雅地分离。 |
容器 | Spring 是一个强大的容器,负责管理对象的配置、创建、装配及其完整的生命周期。 |
框架 | Spring 将简单的组件通过声明式(XML 或注解)的方式组合成复杂的应用,并提供丰富的基础设施。 |
2.4. [重要] 本教程技术栈版本总览
为了确保内容的前沿性与准确性,我们在本教程中将统一使用以下技术栈版本:
- 开发工具 (IDE): IntelliJ IDEA 2024.1.4
- 构建工具 (Build Tool): Apache Maven 3.9.11
- 项目 JDK: Java 17
- Spring Framework: 6.2.9
- 测试框架 (Testing): JUnit 5.13.4
请注意,Spring Framework 6.x 要求 JDK 的最低版本为 Java 17。在开始学习前,请务必确保您的开发环境符合要求。
3. [奠基实践] 第一个 Spring 程序
摘要: 本章我们将使用 Apache Maven 从零搭建一个经典的 Spring 项目。我们将掌握 Spring 依赖的引入、核心配置文件的编写,并最终从 IoC 容器中获取第一个由 Spring 管理的 Bean 对象。
学习指引: 本章将涉及大量 XML 配置和手动创建容器的代码。请务必注意,这在现代 Spring Boot 开发中已 几乎完全被自动化配置和注解所取代。学习本章的目的是为了理解 Spring IoC 的底层工作原理,无需死记硬背 XML 语法。
3.1. 项目搭建与依赖配置
我们将使用 Maven 来构建我们的第一个 Spring 项目。
3.1.1. 创建标准 Maven 项目
首先,我们使用 IDEA 创建一个标准的 Maven 项目,并规划出如下的目录结构:
1 | . 📂 spring6 |
3.1.2. 引入核心依赖
接下来,我们在 pom.xml
文件中声明项目所需的依赖。对于基础 IoC 应用,我们仅需引入 spring-context
。同时,我们使用业界标准的 JUnit 5
作为测试框架。
文件路径: spring6/pom.xml
1 |
|
SpringBoot 简化: Spring Boot 通过 “Starters” 机制(如 spring-boot-starter
)极大地简化了依赖管理,我们无需再逐个添加这些基础依赖。
3.2. 编写第一个 Spring 程序
环境就绪后,我们通过三步来完成第一个 Spring 程序的编写。
3.2.1. 定义一个简单的 Bean
Bean 是 Spring IoC 容器管理的基本单元,本质上就是一个 POJO。
文件路径: src/main/java/com/example/spring6/bean/User.java
1 | package com.example.spring6.bean; |
3.2.2. 创建 Spring 核心配置文件
我们需要一个 XML 配置文件来告诉 Spring 容器需要管理哪些 Bean。
文件路径: src/main/resources/beans.xml
1 |
|
SpringBoot 简化: 在 Spring Boot 中,我们几乎不再使用 XML 文件。通过在 User
类上添加 @Component
注解,它就会被自动扫描并注册为 Bean。
3.2.3. 编写单元测试获取 Bean
最后,我们编写一个 JUnit 5 测试用例来手动启动 Spring 容器,并从中获取 User
对象。
文件路径: com\example\spring6\test\Spring6Test.java
1 | package com.example.spring6.test; |
SpringBoot 简化: Spring Boot 应用的启动入口是 main
方法中的 SpringApplication.run()
,它会自动创建并初始化容器,我们无需手动 new
一个 ApplicationContext
。
3.3. IoC 工作机制深度剖析
第一个程序成功运行的背后,隐藏着 Spring IoC 容器的许多核心工作机制。我们以面试问答的形式,来深入剖析这些细节。
刚才的程序跑通了,我们来深挖一下。<bean>
标签的 id
属性可以重复吗?
不可以。在同一个 Spring 配置文件中,bean
的 id
必须是唯一的,它就像是对象的身份证号。如果重复,容器在启动时就会抛出异常。
很好。那 Spring 底层是如何创建 User
对象的?是不是必须要有无参数构造方法?
是的。Spring IoC 容器本质上是通过 Java 的反射机制 来实例化对象的。它会获取 class
属性指定的类,然后调用该类的 无参构造函数 来创建实例。因此,被 Spring 管理的 Bean 必须提供一个无参数构造器。
原来如此。那 Spring 把这些创建好的 Bean 实例存放在哪里了呢?
Spring 容器内部会维护一个类似 Map<String, Object>
的数据结构,我们通常称之为“单例池” (Singleton Cache)。配置的每个 <bean>
都会被实例化成一个对象,并以其 id
为键 (key),以对象实例为值 (value) 存放在这个 Map 中。
getBean()
方法的返回值是 Object
,如果我想直接调用 User
的方法,每次都要强转,有没有更便捷的方式?
有的。getBean()
方法有一个重载版本,可以传入一个 Class 类型的参数。像这样:User user = applicationContext.getBean("userBean", User.class);
这样获取到的直接就是指定类型的对象,无需手动强转。
不错。最后问一个,ApplicationContext
和它的父接口 BeanFactory
有什么区别?
BeanFactory
是 Spring IoC 容器的顶级接口,定义了获取 Bean 的最基本方法,是“Bean 工厂”的本质。而 ApplicationContext
是它的子接口,功能更强大。它除了继承 BeanFactory
的所有功能外,还额外提供了对国际化 、事件发布 、AOP 等企业级特性的支持。我们通常推荐使用 ApplicationContext
。
3.4. [推荐实践] 集成 Log4j2 日志框架
专业的应用程序离不开日志系统。从 Spring 5 开始,官方推荐使用 Log4j2 作为集成的日志框架。
3.4.1. 添加依赖
我们需要在 pom.xml
中添加 log4j-core
和 log4j-slf4j2-impl
两个依赖。
3.4.2. 添加配置文件
按照约定,我们需要在 src/main/resources
目录下创建一个名为 log4j2.xml
的配置文件。
文件路径: src/main/resources/log4j2.xml
1 |
|
SpringBoot 简化: Spring Boot 的 spring-boot-starter-logging
提供了开箱即用的日志功能(默认 Logback),我们通常只需在 application.properties
中配置级别即可。
总结: 至此,我们已经完整地搭建了一个最小化的、基于 Maven 和 XML 配置的 Spring IoC 应用,并为其配备了专业的日志系统。这是后续所有学习的坚实基础。
4. [核心实践] 依赖注入详解:构筑对象关系的艺术
摘要: IoC
是一种思想,而 DI
是其最重要的实现。本章是 Spring 学习的重中之重。我们将系统性地学习如何通过 XML 配置,将不同类型的依赖(其他 Bean、字面量、集合等)注入到目标对象中,并掌握 p/c 命名空间等简化配置的实用技巧。
4.1. 注入的两种主要方式:Setter 注入 vs 构造器注入
依赖注入(DI)的本质,是容器将一个对象所依赖的其他对象(或值)“注入”到该对象中的过程。Spring 提供了两种主要的注入方式:
- Setter 注入: 这是最常用、最灵活的方式。容器先通过无参构造器创建 Bean 实例,然后调用该实例的
setXxx()
方法来完成依赖注入。它的优点是允许我们选择性地注入依赖,非常灵活。 - 构造器注入: 容器通过调用 Bean 的构造方法,在实例化 Bean 的同时就完成依赖注入。它的优点是能保证依赖在对象创建时就已就绪,通常用于注入那些必不可少的、不可变的依赖。
我们将从最经典的 Setter 注入开始,深入探索其各种应用场景。
4.2. Setter 注入深度实践
4.2.1. 注入其他 Bean 对象 (ref)
这是最常见的场景:一个 Bean 依赖于另一个 Bean。例如,UserService
的运行需要依赖一个 UserDao
对象来操作数据库。
1. 准备代码
我们在之前的 spring6
项目中继续操作,首先创建 UserDao
和 UserService
两个类。
文件路径: src/main/java/com/example/spring6/bean/UserDao.java
(新增文件)
1 | package com.example.spring6.bean; |
文件路径: src/main/java/com/example/spring6/bean/UserService.java
(新增文件)
1 | package com.example.spring6.bean; |
2. 配置与测试
接下来,我们需要在 Spring 的 XML 配置文件中,明确声明这两个 Bean 以及它们之间的依赖关系。
文件路径: src/main/resources/beans.xml
(修改)
1 |
|
测试代码:
文件路径: src/test/java/com/example/spring6/test/DITest.java
(新增测试类)
1 | package com.example.spring6.test; |
在 Spring Boot 中,我们可以使用注解方式来简化 Bean 的注入和配置。
Java 代码:
文件路径: src/main/java/com/example/spring6/bean/UserDao.java
(新增文件)
1 | package com.example.spring6.bean; |
Java 代码:
文件路径: src/main/java/com/example/spring6/bean/UserService.java
(新增文件)
1 | package com.example.spring6.bean; |
测试代码:
文件路径: src/test/java/com/example/spring6/test/DITest.java
(修改)
1 | package com.example.spring6.test; |
4.2.2. 注入字面量/简单类型 (value)
除了注入对象,我们还可以注入字符串、数字、布尔值等简单类型,这些值被称为“字面量”。
1. 修改 User
类
我们为之前的 User
类添加 name
(String) 和 age
(int) 属性,并提供对应的 setter 方法。
文件路径: src/main/java/com/example/spring6/bean/User.java
(修改)
1 | package com.example.spring6.bean; |
2. 配置与测试
配置思路解读
当注入的是字面量时,我们同样使用 <property>
标签,但与之配合的是 value
属性,而不是 ref
属性。下面我们对比三种主流的配置方案。
这种方式最简单,Java 类是纯净的 POJO,不需要任何 Spring 注解。
XML 配置:
1 | <bean id="userBean" class="com.example.spring6.bean.User"> |
Java 代码:
1 | // User.java (无需任何注解) |
这种混合方式下,Bean 的定义仍在 XML 中,但值的注入可以通过注解完成。前提是必须在 XML 中开启注解处理。
XML 配置:
1 | <context:annotation-config/> |
Java 代码:
1 | // User.java (无需 @Component) |
这是现代 Spring/Spring Boot 的标准做法,XML 中只需开启组件扫描即可。
XML 配置:
1 | <beans xmlns="http://www.springframework.org/schema/beans" |
Java 代码:
1 | // User.java |
测试代码:
1 |
|
1
User{name='Prorise', age=25}
4.2.3. 注入集合类型 (List, Set, Map, Properties)
Spring 提供了专门的 <list>
, <set>
, <map>
, <props>
标签来为集合类型的属性注入值。
1. 新增 Person
类
文件路径: src/main/java/com/example/spring6/bean/Person.java
(新增文件)
1 |
|
2. 配置与测试
配置思路解读:
这种方式将所有配置硬编码在 XML 文件中,每个集合类型都有其对应的专属标签。
XML 配置:
文件路径: src/main/resources/beans-collection.xml
(新增配置文件)
1 |
|
测试代码:
1 |
|
配置思路解读:
Spring Boot 的核心思想是 约定大于配置 和 配置外部化。我们将所有配置数据移至 application.yml
文件中,并通过 @ConfigurationProperties
注解,以类型安全的方式将这些数据绑定到 Java 对象上。
YAML 配置:
文件路径: src/main/resources/application.yml
(新增或修改)
1 | # 将所有配置结构化地定义在 yml 文件中 |
Java 代码:
文件路径: src/main/java/com/example/spring6/bean/Person.java
(修改)
1 | package com.example.spring6.bean; |
测试代码 (Spring Boot 风格):
1 | import org.junit.jupiter.api.Test; |
4.2.4. 处理特殊值:注入 null
与含特殊符号的 CDATA
注入 null
值
如果你需要明确地将一个 null
注入给属性,可以使用 <null/>
标签。
1 | <bean id="userBean" class="com.example.spring6.bean.User"> |
注入含特殊符号的字符串
XML 中 <
、>
、&
等是特殊字符。如果你的字符串值包含它们,需要使用 CDATA
块来包裹,以避免 XML 解析错误。
1 | <bean id="mathBean" class="com.example.spring6.bean.MathBean"> |
4.3. 构造器注入的应用场景与配置
当一个依赖是 必需的,我们希望在对象创建时就保证其存在,这时就应该使用构造器注入。
1. 修改 UserService
我们将 UserService
修改为通过构造器接收 UserDao
。
文件路径: src/main/java/com/example/spring6/bean/UserService.java
(修改)
1 | package com.example.spring6.bean; |
2. 配置与测试
1 | <bean id="service1" class="com.example.spring6.bean.UserService"> |
注意:
@Service
以及相关注解属于 springframework, 并不只属于 Spring-boot,所以在没有 Springboot 的环境也是可以运行的,不要搞混!
1 | package com.example.spring6.bean; |
由于我们之前开启过组件扫描了,所以我们将 beans.xml
的 userServiceBean
删除即可
4.4. 简化 XML:p 命名空间与 c 命名空间
SpringBoot 简化: 由于现代开发全面转向注解,p
和 c
命名空间已成为历史,我们了解即可,完全无需记忆。
为了简化 XML 的冗长写法,Spring 提供了 p
和 c
两个命名空间。
p
命名空间:用于简化 Setter 注入 (p
for property)。c
命名空间:用于简化 构造器注入 (c
for constructor)。
1. 添加命名空间声明
首先,需要在 <beans>
根标签上添加 p
和 c
的声明。
1 | <beans xmlns="http://www.springframework.org/schema/beans" |
2. 使用示例
1 | <bean id="user1" class="com.example.spring6.bean.User"> |
4.5. 外部化配置:引入 .properties
属性文件
在实际项目中,数据库连接信息等敏感、易变的数据不应硬编码在 XML 中。正确的做法是将其放在外部的 .properties
文件中,由 Spring 动态加载。
1. 创建 jdbc.properties
文件
文件路径: src/main/resources/jdbc.properties
(新增文件)
1 | db.driver=com.mysql.cj.jdbc.Driver |
2. 添加 context
命名空间并加载文件
我们需要 context
命名空间下的 <context:property-placeholder>
标签来加载属性文件。
文件路径: src/main/resources/beans-db.xml
(新增配置文件)
1 |
|
SpringBoot 简化: Spring Boot 约定大于配置,它会自动加载 src/main/resources/application.properties
或 application.yml
文件。我们只需在类中使用 @Value("${db.driver}")
注解即可直接注入属性值,或通过 @ConfigurationProperties
进行类型安全的属性绑定,比 XML 配置简洁得多。
3.Spring Boot 示例
在 Spring Boot 中,我们可以通过直接在 application.properties
文件中进行配置,使用 @Value
或 @ConfigurationProperties
注解来实现外部化配置的功能。
application.properties
配置:
文件路径: src/main/resources/application.properties
(新增或修改)
1 | db.driver=com.mysql.cj.jdbc.Driver |
Java 代码:
文件路径: src/main/java/com/example/spring6/config/DataSourceConfig.java
(新增配置类)
1 | package com.example.spring6.config; |
application.yml
配置:
文件路径: src/main/resources/application.yml
(替代 application.properties
,如果需要使用 YAML 格式)
1 | db: |
Java 代码: 使用 @ConfigurationProperties
绑定配置属性
1 | package com.example.spring6.config; |
本章总结: 至此,我们已经全面掌握了在 Spring 经典 XML 配置中进行依赖注入的各种核心技能。虽然现代开发已更多地转向注解,但理解这些基于 XML 的配置原理,对于我们深入把握 Spring 的 IoC 本质、排查疑难问题具有不可替代的价值。
5. [进阶配置] Bean 的高级管理
摘要: 掌握了基础的 DI 配置后,本章我们将深入探索 Bean 的更多高级特性。我们将学习 Bean 的作用域如何影响其实例数量,Bean 的完整生命周期流程,以及 Spring 创建 Bean 的多种底层方式,包括强大的 FactoryBean。
5.1. Bean 的作用域 (Scope)
在 Spring 中,作用域 (Scope) 决定了 Spring IoC 容器如何创建和管理 Bean 的实例。简单来说,它回答了这样一个问题:“当我从容器中请求一个 Bean 时,是每次都给我一个新的对象,还是始终给我同一个?”。正确地使用作用域对于应用的性能和状态管理至关重要。
Spring 定义了多种作用域,但最核心、最常用的只有两种:singleton
(单例) 和 prototype
(原型/多例)。
5.1.1. 详解 singleton
:唯一的实例
singleton
是 Spring 默认 的作用域。当一个 Bean 的作用域为 singleton
时,无论你从容器中获取多少次该 Bean,Spring 容器都 只会返回同一个共享的实例。
1. 特性与创建时机
- 唯一实例: 在整个 Spring IoC 容器的生命周期内,一个 Bean ID 只对应一个对象实例。
- 创建时机: 默认情况下,
singleton
作用域的 Bean 在 容器启动和初始化时 就会被创建并放入一个缓存区(俗称“单例池”),等待后续的注入和调用。
2. 实践验证
我们在 spring6
项目中进行验证。
文件路径: src/main/java/com/example/spring6/bean/SpringBean.java
(新增文件)
1 | package com.example.spring6.bean; |
文件路径: src/main/resources/spring-scope.xml
(新增配置文件)
1 |
|
测试代码:
文件路径: src/test/java/com/example/spring6/test/ScopeTest.java
(新增测试类)
1 | package com.example.spring6.test; |
1
2
3
SpringBean 的无参数构造方法执行了...
sb1 = com.example.spring6.bean.SpringBean@2c88b9fc
sb2 = com.example.spring6.bean.SpringBean@2c88b9fc
结果分析: 构造方法只执行了一次,并且两次获取到的对象地址是完全相同的,证明了 singleton
的唯一性。
运行 testSingletonCreateTiming
测试:
1 | SpringBean 的无参数构造方法执行了... |
结果分析: 即使我们没有调用 getBean()
,构造方法依然在容器初始化时就被执行了,证明了其“饿汉式”的创建时机。
5.1.2. 详解 prototype
:多变的实例
与 singleton
相对,prototype
作用域的 Bean 每次被请求时,Spring 容器都会 创建一个全新的实例 返回。
1. 特性与创建时机
- 全新实例: 每一次
getBean()
调用或每一次注入操作,都会触发一次全新的对象创建过程。 - 创建时机:
prototype
作用域的 Bean 是 懒加载 的。只有当它被实际请求(getBean()
)时,容器才会去创建它的实例。 - 生命周期管理: Spring 容器在创建并初始化
prototype
Bean 后,就会将其交给调用方,不再追踪其后续的生命周期。这意味着 Spring 不会为prototype
Bean 调用其销毁方法 (destroy-method
)。
2. 实践验证
文件路径: src/main/resources/spring-scope.xml
(修改)
1 | <bean id="sb" class="com.example.spring6.bean.SpringBean" scope="prototype"/> |
测试代码: 使用与上面完全相同的 ScopeTest.java
。
运行 testSingletonScope
测试 (现在 sb Bean 是 prototype):
1 | SpringBean 的无参数构造方法执行了... |
结果分析: 构造方法被执行了两次,并且两次获取到的对象地址是不同的,证明了 prototype
的多例性。
运行 testSingletonCreateTiming
测试 (现在 sb Bean 是 prototype):
1 | (无任何输出) |
结果分析: 仅创建容器而没有 getBean()
时,构造方法完全没有被执行,证明了其“懒加载”的创建时机。
5.1.3. Spring Boot 对比:使用 @Scope
注解
在 Spring Boot 中,我们使用 @Scope
注解来声明 Bean 的作用域,这比 XML 配置更加直观和便捷。
通过在 <bean>
标签上设置 scope
属性来定义作用域。
1 | <bean id="singletonBean" class="com.example.spring6.bean.SpringBean" /> |
通过在 Bean 的声明注解(如 @Component
, @Service
)之上添加 @Scope
注解来定义作用域。
Java 代码:
1 | // SpringBean.java |
5.2. Bean 的实例化方式
我们知道,Spring 创建 Bean 最常规的方式是调用其无参构造函数。但在真实的开发世界里,对象的创建过程远比 new User()
要复杂得多。本节,我们将聚焦于那些“非常规”的实例化场景,并探索 Spring 是如何通过多种灵活的机制来优雅地应对它们的。
5.2.1. 构造方法实例化 (回顾)
这是标准场景,我们不再赘述。当一个类拥有公开的无参构造器时,Spring 默认通过它来创建实例。
1 | <bean id="userBean" class="com.example.spring6.bean.User"/> |
5.2.2. 静态工厂方法 (factory-method
)
“痛点”场景:
假设我们需要集成一个公司内部的遗留工具库。这个库中有一个
LegacyApiClient
类,它的构造函数是private
的,我们无法直接new
它。幸运的是,这个类提供了一个静态方法public static LegacyApiClient getInstance()
来获取其单例对象。那么,我们如何在 Spring XML 中配置并管理这个由静态方法创建的对象呢?
解决方案:
Spring 提供了 factory-method
属性,专门用于调用一个静态方法来创建 Bean 实例。
1. 准备代码 (模拟遗留库)
文件路径: src/main/java/com/example/spring6/bean/LegacyApiClient.java
1 | package com.example.spring6.bean; |
2. 配置与测试
XML 配置: src/main/resources/spring-instantiation.xml
(新增配置文件)
1 |
|
测试代码:
1 |
|
1
2
遗留 API 客户端实例已创建...
com.example.spring6.bean.LegacyApiClient@636e8cc
结论: 我们成功地让 Spring 通过调用 LegacyApiClient.getInstance()
方法,将这个原本无法直接实例化的对象纳入了容器管理。
5.2.3. 实例工厂方法 (factory-bean
)
“痛点”场景:
现在情况变得更复杂。我们需要一个数据库连接池
ConnectionPool
对象,但这个对象不能直接new
,必须由一个ConnectionPoolManager
的实例来创建。更关键的是,这个manager
对象本身在创建pool
之前,需要先进行配置(比如设置最大连接数maxConnections
)。我们如何让 Spring 先创建并配置好manager
,然后再调用这个配置好的manager
实例的createPool()
方法来创建pool
Bean 呢?
解决方案:
这就是 factory-bean
属性的用武之地。它允许我们指定一个已经存在的 Bean 实例作为工厂,来创建另一个 Bean。
1. 准备代码
文件路径: src/main/java/com/example/spring6/bean/ConnectionPool.java
1 | package com.example.spring6.bean; |
文件路径: src/main/java/com/example/spring6/bean/ConnectionPoolManager.java
1 | package com.example.spring6.bean; |
2. 配置与测试
XML 配置: src/main/resources/spring-instantiation.xml
(修改)
1 | <bean id="poolManager" class="com.example.spring6.bean.ConnectionPoolManager"> |
测试代码:
1 |
|
1
2
实例工厂 ConnectionPoolManager 正在创建连接池,最大连接数:10
com.example.spring6.bean.ConnectionPool@4b45a2f5
5.2.4. FactoryBean
接口:一种特殊的工厂 Bean
“痛点”场景:
在项目中,我们可能需要根据配置文件中的一个
type
值(例如 ‘simple’ 或 ‘complex’)来决定创建一个SimpleMessageConverter
还是ComplexMessageConverter
。这个创建逻辑如果散落在业务代码中会非常混乱。我们希望将这种复杂的、有条件的创建逻辑封装成一个可重用的 Spring 组件,让这个“工厂”本身可以被配置,并由 Spring 来调用它生产最终的 Bean。
解决方案:
Spring 提供了一个完美的、框架原生的解决方案:实现 FactoryBean
接口。它允许我们将复杂的实例化逻辑封装在一个类中,使其成为一个专门为 Spring 生产 Bean 的“工厂 Bean”。
1. 准备代码
文件路径: src/main/java/com/example/spring6/bean/MessageConverter.java
1 | package com.example.spring6.bean; |
文件路径: src/main/java/com/example/spring6/bean/MessageConverterFactoryBean.java
1 | package com.example.spring6.bean; |
2. 配置与测试
XML 配置: src/main/resources/spring-instantiation.xml
(修改)
1 | <bean id="messageConverter" class="com.example.spring6.bean.MessageConverterFactoryBean"> |
测试代码:
1 |
|
1
2
FactoryBean 正在根据 type='complex' 创建 MessageConverter...
获取到的 Bean 类型: ComplexMessageConverter
5.2.5. Spring Boot 对比:使用 @Configuration
和 @Bean
方法
“痛点”场景:
我们已经看到了 XML 中各种工厂方法的威力,但 XML 配置是字符串形式的,类型不安全(写错类名或方法名在编译期无法发现),难以重构(IDE 无法方便地查找引用和重命名),而且配置和逻辑是分离的。在 Spring Boot 中,有没有一种既能实现所有这些复杂创建逻辑,又具备 Java 代码所有优点的现代方式呢?
解决方案:
当然有!这就是 Spring Boot 的核心配置机制:@Configuration
类和 @Bean
方法。它允许我们用纯 Java 代码来定义和配置 Bean。
- 静态工厂:
1
<bean class="...Factory" factory-method="get"/>
- 实例工厂:
1
2<bean id="myFactory" class="...Factory"/>
<bean factory-bean="myFactory" factory-method="get"/> - FactoryBean:
1
<bean id="product" class="...ProductFactoryBean"/>
配置思路解读:
我们创建一个被 @Configuration
注解的类,这个类就相当于一个 XML 配置文件。类中所有被 @Bean
注解的方法,其返回值都会被 Spring 注册为一个 Bean,方法名默认就是 Bean 的 ID。
Java 配置: src/main/java/com/example/spring6boot/config/AppConfig.java
(新增文件)
1 | package com.example.spring6boot.config; |
对比总结: Spring Boot 的 Java 配置方式 (@Configuration
+ @Bean
) 完胜 XML 配置。它提供了完全的类型安全(所有东西都是 Java 代码,编译器会检查错误)、更好的可重构性(可以利用 IDE 的全部功能),并且能以编程方式实现任意复杂的 Bean 创建逻辑。这是现代 Spring 应用配置 Bean 的标准和推荐方式。
5.3. Bean 的生命周期
一个 Bean 在 Spring IoC 容器中,从被创建到最终被销毁,会经历一系列预定义的阶段,这就是 Bean 的生命周期。理解这个过程至关重要,因为它为我们提供了在特定时间点介入、执行自定义逻辑的机会。
“痛点”场景:
假设我们有一个
DatabaseManager
Bean,它负责管理数据库连接。我们必须确保,在它所有必需的属性(如url
,username
)被 Spring 注入之后,立刻调用openConnection()
方法来建立连接;而在整个应用关闭、容器销毁这个 Bean 之前,必须调用closeConnection()
方法来安全地释放资源。我们如何才能将这两个关键操作精确地安插在这两个时间点呢?
解决方案:
Spring 强大的生命周期回调机制,正是为了解决这类问题而设计的。它允许我们指定特定的方法,在 Bean 初始化和销毁时自动执行。
5.3.1. 核心五步:从实例化到销毁
我们可以将一个 Bean 的生命周期粗略地划分为五个核心阶段,使用时间线来展示最为清晰:
bean生命周期
Instantiation
Spring 容器通过构造方法创建 Bean 的实例。
Populate Properties
Spring 容器通过 DI 为 Bean 的属性注入值。
Initialization
执行开发者自定义的初始化逻辑,例如 openConnection()
。
In Use
Bean 处于可用状态,可以被应用程序中的其他对象调用。
Destruction
容器关闭时,执行自定义的销毁逻辑,例如 closeConnection()
。
实践验证
文件路径: src/main/java/com/example/spring6/bean/LifecycleBean.java
1 | package com.example.spring6.bean; |
文件路径: src/main/resources/spring-lifecycle.xml
1 |
|
文件路径: src/test/java/com/example/spring6/test/LifecycleTest.java
1 | package com.example.spring6.test; |
1
2
3
4
5
1. 实例化 Bean (调用构造器)
2. Bean 属性赋值 (调用 setter)
3. 初始化 Bean (调用自定义 init 方法)
4. 使用 Bean
5. 销毁 Bean (调用自定义 destroy 方法)
结论: 通过 init-method
和 destroy-method
,我们成功地将自定义逻辑挂载到了 Bean 的初始化和销毁阶段。
5.3.2. 关键扩展点:BeanPostProcessor
“痛点”场景:
init-method
很好用,但它只能对单个 Bean 生效。如果我们想对容器中所有(或某一批)的 Bean,在它们各自的init
方法执行前后,都统一执行一段逻辑(比如打印日志、创建代理对象等),难道要修改每一个 Bean 的配置吗?这显然不现实。
解决方案:
这是一种典型的“横切关注点”,Spring 提供了其体系中最强大的扩展点之一:Bean 后置处理器 (BeanPostProcessor
)。
加入后置处理器后,生命周期演变为更精细的七个步骤:
- 1.创建 Bean 实例。
- 2.为 Bean 注入依赖。
- 3.Bean后置处理器前置处理:
init-method
执行前的第一个扩展点。 - 4.执行 Bean 自定义的
init-method
。 - 5.Bean后置处理器后置处理:
init-method
执行后的第二个扩展点,常用于创建代理对象。 - 6.Bean 处于可用状态。
- 7.执行 Bean 自定义的
destroy-method
。
实践验证
文件路径: src/main/java/com/example/spring6/bean/LogBeanPostProcessor.java
1 | package com.example.spring6.bean; |
文件路径: src/main/resources/spring-lifecycle.xml
(修改)
1 | <bean class="com.example.spring6.bean.LogBeanPostProcessor"/> |
再次运行上一节的 testLifecycleFiveSteps()
测试。
1 | 1. 实例化 Bean (调用构造器) |
5.3.3. Spring Boot 对比:使用注解定义生命周期
通过在 <bean>
标签上设置 init-method
和 destroy-method
属性来指定回调方法,并通过定义 <bean>
来注册后置处理器。
1 | <bean id="lifecycleBean" |
对比总结: @PostConstruct
和 @PreDestroy
是 JSR-250 标准的一部分,这意味着您的代码不直接依赖于 Spring 的特定 API,具有更好的可移植性和通用性。这是在现代 Spring Boot 应用中处理生命周期回调的首选方式。
6. [原理剖析] 深入 IoC 容器
摘要: 本章我们将探讨 Spring IoC 容器中两个非常重要的底层话题。首先是经典的面试难题——循环依赖,我们将从原理上剖析 Spring 是如何通过三级缓存解决它的。其次是作为 Spring 设计思想基石的工厂模式。
6.1. Bean 的循环依赖问题 (面试高频)
首先,我们需要理解什么是循环依赖,以及为什么它在 IoC 容器中是一个值得被探讨的问题。
6.1.1. "痛点"场景: 什么是循环依赖?
循环依赖(Circular Dependency),也称为循环引用,指的是两个或多个 Bean 之间相互持有对方的引用,形成一个闭环。最简单的场景就是 A 依赖 B,同时 B 又依赖 A。
A -> B -> A
一个生动的比喻:
想象一下“丈夫”(Husband
)和“妻子”(Wife
)两个类。Husband
对象有一个 Wife
类型的属性,Wife
对象也有一个 Husband
类型的属性。当 Spring 容器试图创建它们时,就会遇到一个难题:
- 要创建
Husband
实例,必须先注入一个Wife
实例。 - 但要创建
Wife
实例,又必须先注入一个Husband
实例。这就形成了一个看似无解的“死循环”。
准备代码:
我们将使用这个“夫妻”模型来贯穿本节的实验。
文件路径: src/main/java/com/example/spring6/bean/Husband.java
(新增文件)
1 | package com.example.spring6.bean; |
文件路径: src/main/java/com/example/spring6/bean/Wife.java
(新增文件)
1 | package com.example.spring6.bean; |
6.1.2. 实践验证:Spring 能否解决循环依赖?
Spring 能否解决循环依赖,取决于 Bean 的作用域和注入方式。让我们通过三个核心场景来一探究竟。
准备配置文件:
文件路径: src/main/resources/spring-dependency.xml
(新增配置文件)
场景一: singleton
+ Setter 注入 (成功)
这是最常见的场景:两个 Bean 都是单例,并且通过 Setter 方法相互注入。
XML 配置:
1 |
|
测试代码:
1 |
|
1
2
Husband{name='张三', wifeName=小红}
Wife{name='小红', husbandName=张三}
结论: Spring 完美地解决了单例 Bean 通过 Setter 方式注入的循环依赖问题。
场景二: prototype
+ Setter 注入 (失败)
如果我们将两个 Bean 的作用域都改为 prototype
,情况会如何?
XML 配置 (修改 scope
):
1 | <bean id="husband" class="com.example.spring6.bean.Husband" scope="prototype"> |
1
2
3
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'husband' defined in class path resource [spring-dependency.xml]: Cannot resolve reference to bean 'wife' while setting bean property 'wife'
at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:377)
... (省略堆栈信息)
结论: Spring 无法解决 prototype
作用域 Bean 的循环依赖问题,并会抛出 BeanCurrentlyInCreationException
异常。
场景三: singleton
+ 构造器注入 (失败)
我们回到 singleton
作用域,但改用构造器进行注入。
准备构造器注入的代码:
1 | // HusbandWithConstructor.java |
XML 配置:
1 | <bean id="husband" class="com.example.spring6.bean.HusbandWithConstructor"> |
1
2
3
4
org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'husband': Requested bean is currently in creation:
Is there an unresolvable circular reference?
... (省略堆栈信息)
结论: Spring 同样无法解决构造器注入方式的循环依赖问题,即使 Bean 是单例的。
6.1.3. 原理浅析:Spring 如何通过“三级缓存”解开死结?
为什么只有“singleton
+ Setter 注入”的组合能成功?这背后的功臣,就是 Spring IoC 容器内部精巧的“三级缓存”机制。
刚才的实验结果很有趣。你能量化一下,为什么 Spring 能解决 singleton 和 Setter 的循环依赖吗?
核心原因在于 Spring 将 Bean 的创建过程分为了两步:1. 实例化 和 2. 属性填充。对于单例 Bean,Spring 可以在完成第一步实例化之后,不等第二步属性填充完成,就将这个半成品的对象提前暴露出去。
很好。那 “提前曝光” 是通过什么机制实现的呢?能具体谈谈吗?
它是通过一个位于 DefaultSingletonBeanRegistry 类中的三级缓存来实现的。
一级缓存 : 存放已完成初始化的单例 Bean,是最终的成品区。
二级缓存 : 存放提前曝光的、未完成属性填充的半成品单例 Bean。
三级缓存 : 存放能生产半成品 Bean 的工厂 (ObjectFactory)。
能描述一下创建 husband 和 wife 的完整流程吗?
当然。
- getBean(“husband”),三级缓存中都没有,开始创建。
- new Husband() 实例化一个半成品 husband 对象。
- Spring 并不立即填充其 wife 属性,而是将一个能获取这个半成品 husband 的工厂放入三级缓存。
- 开始填充 husband 的属性,发现它需要 wife。
- getBean(“wife”),三级缓存中都没有,开始创建。
- new Wife() 实例化一个半成品 wife 对象,并同样将其工厂放入三级缓存。
- 开始填充 wife 的属性,发现它需要 husband。
- 再次 getBean(“husband”),此时 Spring 从三级缓存中找到了 husband 的工厂,通过工厂拿到半成品 husband 对象,并将其放入二级缓存。
- wife 成功获取到半成品 husband 的引用,完成属性填充和初始化,成为一个成品。随后,wife 被放入一级缓存。
- husband 也成功获取到成品 wife 的引用,完成自己的属性填充和初始化,也成为成品,并被放入一级缓存。循环依赖解决。
非常清晰!那最后请解释一下,为什么构造器注入和 prototype 作用域就不行呢?
构造器注入 的问题在于,它的实例化和属性填充是在同一步 (new Husband(wife)
) 中完成的。在 new 的那一刻就必须拿到 wife 的实例,无法提前曝光一个半成品,所以陷入了死循环。
prototype 作用域 的问题在于,Spring 容器不对 prototype Bean 进行缓存。每次请求都是全新的,也就没有了可以存放半成品的缓存机制,自然也无法解决循环依赖。
6.2. [背景知识] 工厂设计模式
6.2.1. "痛点"场景: 为什么我们需要工厂?
在面向对象编程的初期,我们创建对象的方式通常非常直接:
1 | public class Client { |
这段代码存在一个严重的问题:客户端 (Client
) 与具体的产品 (Tank
, Fighter
) 强耦合。
- 缺乏弹性: 如果将来我们新增一种武器
Dagger
,就必须修改Client
类的代码。 - 违反开闭原则 (OCP): 我们的系统对“扩展”(增加新武器)是关闭的,因为扩展需要“修改”现有代码。
解决方案:
引入一个“工厂”角色,专门负责创建对象。客户端不再关心对象是如何被创建的,只管向工厂索要即可。这就实现了创建过程与使用过程的分离。
6.2.2. 模式概览: 简单工厂 vs 工厂方法
简单工厂模式
这是最基础的工厂模式,它将所有产品的创建逻辑集中在一个工厂类中。
1. 准备代码
文件路径: src/main/java/com/example/spring6/factory/Weapon.java
(及实现类)
1 | package com.example.spring6.factory; |
文件路径: src/main/java/com/example/spring6/factory/WeaponFactory.java
1 | package com.example.spring6.factory; |
2. 客户端调用
文件路径: src/main/java/com/example/spring6/factory/Client.java
1 | package com.example.spring6.factory; |
1
2
坦克开炮!
战斗机投弹!
优点: 实现了创建和使用的分离。
缺点: 违反了开闭原则。每当新增一种武器,我们都必须修改 WeaponFactory
类的 get
方法
工厂方法模式
为了解决简单工厂的 OCP 问题,工厂方法模式将工厂也进行了抽象。
核心思想: 不再由一个全能工厂来创建所有产品,而是为每一种产品都提供一个专门的工厂。
1. 准备代码
我们继续使用上面的 Weapon
产品类。
文件路径: src/main/java/com/example/spring6/factory/WeaponFactory.java
(修改为接口)
1 | package com.example.spring6.factory; |
文件路径: src/main/java/com/example/spring6/factory/TankFactory.java
(等具体工厂)
1 | package com.example.spring6.factory; |
2. 客户端调用
1 | public class Client { |
1
2
坦克开炮!
战斗机投弹!
优点: 完美遵循了开闭原则。如果现在需要新增 Dagger
武器,我们只需新增一个 DaggerFactory
即可,完全不需要修改任何现有代码。
缺点: 当产品种类非常多时,会导致工厂类的数量急剧增加。
6.2.3. Spring 中的体现: BeanFactory
与 FactoryBean
的区别
理解了工厂模式后,我们就可以来辨析 Spring 中两个最容易混淆的核心概念了。
我们经常听说 Spring 是一个大工厂,也常提到 BeanFactory
和 FactoryBean
。它们之间有什么关系和区别?
这是一个经典问题。虽然名字很像,但它们是两个完全不同维度的概念。
BeanFactory 是工厂。它是 Spring IoC 容器的顶级接口,是 Spring 框架的“心脏”,是管理所有 Bean 的“总工厂”。它的职责是定义如何获取和管理 Bean,是 Spring IoC 功能的基石。我们通常使用的 ApplicationContext
就是它的一个功能更强大的子接口。
FactoryBean 是一个 Bean。它是一个可以被总工厂 (BeanFactory
) 管理的、比较特殊的 Bean。特殊之处在于,它本身的作用不是给自己用,而是作为一个“小型、可插拔的零件工厂”,去生产另一个 Bean。
那么从容器中获取 Bean 时,这两者有什么不同?
这是最关键的区别。假设我们有一个 id
为 myCarEngine
的 FactoryBean
。
当我们调用 context.getBean("myCarEngine")
时,我们得到的不是这个 FactoryBean
本身,而是它内部 getObject()
方法返回的那个“发动机”对象。
如果我们确实需要 FactoryBean
这个“机床”本身,需要使用一个 &
符号,像这样调用:context.getBean("&myCarEngine")
。
总结来说,BeanFactory 是管理者,FactoryBean 是被管理者中的一个特殊工匠。
重要信息: 后续内容还有重要的注解开发和面向切面AOP的核心,并不是笔记这里不讲了,是我认为他更适合在Spring-boot章节来讲,我们对比了这么多SpringBoot相关的知识点,所以我们与其学习Spring的基础用法,不如直接过渡到Spring-boot中,相信我,在学习了以上内容之后,过度到Spring-boot是最好契机!