嘿,朋友。看到你盯着屏幕上的 pom.xml 或者 build.gradle 发呆,眉头紧锁的样子,我太懂了。Spring 就像是一个穿着西装、戴着单片眼镜的老派管家,它优雅、强大,但如果你不懂它的规矩,它就会给你甩脸色——比如那个让人抓狂的 BeanCreationException,或者更经典的 NoSuchBeanDefinitionException。
别怕,我们不需要去背诵那几百万行的源码,我们需要的是那种“我知道它为什么这么干,我也知道怎么让它闭嘴干活”的掌控感。今天,我们不搞那些教科书式的定义堆砌,咱们像老工匠修车一样,把 Spring 这个复杂的引擎拆开来看看,从最基础的 Hello World 开始,一直聊到怎么让你的企业级应用跑得比法拉利还稳。
第一章:别急着写代码,先理解“容器”到底是个啥
很多新手一上来就复制粘贴 @SpringBootApplication,然后问:“老师,我的 Bean 怎么注入不进去?”
你要明白,Spring 的核心就是一个容器(Container)。你可以把它想象成一个超级智能的储物柜。
- 传统开发:你需要一个对象,你就手动
new Object()。这就像是你想喝水,你得自己去打井、买杯子、烧水。累吗?累。而且如果井干了(依赖缺失),你就喝不到水。 - Spring 开发:你告诉储物柜(容器):“我需要一杯水,品牌是‘农夫山泉’。” 然后你坐在沙发上等着。容器会自动去帮你找、去买、去倒水,最后递到你手里。
这个过程叫 IoC(控制反转)。而容器帮你把水递到你手里的那个动作,就是 DI(依赖注入)。
1.1 第一个坑:你以为的 Bean 和实际的 Bean
让我们看一个最简单的例子。假设我们要写一个用户服务。
// 这是一个普通的 Java 类,但在 Spring 眼里,它只是一个普通的 Java 类
public class UserService {
public void sayHello() {
System.out.println("Hello from UserService");
}
}
这时候,如果你在另一个地方直接 new UserService(),没问题。但如果你想通过 Spring 来获取它,你必须告诉 Spring:“嘿,这个类归我管,请帮我管理它的生命周期。”
错误示范:
@Service // 哎呀,忘了加这个注解!
public class UserService {
// ...
}
如果你忘了加 @Service、@Component 或者在配置类里声明 @Bean,Spring 启动时根本不知道 UserService 的存在。当你试图注入它时,就会报错:
No qualifying bean of type 'com.example.UserService' available
真相是: Spring 不会自动扫描所有的类。它只扫描它被标记为 Bean 的类。这就好比你告诉储物柜管理员“帮我存个苹果”,但你没把苹果给他,他当然找不到苹果。
解决方案:
确保你的组件被正确扫描。通常 @SpringBootApplication 自带 @ComponentScan,它会扫描当前包及其子包。所以,把 UserService 放在主启动类的同级或子包下,并加上 @Service 注解。
package com.example.demo.service;
import org.springframework.stereotype.Service;
@Service // 明确告诉 Spring:我是 Bean,请管理我
public class UserService {
public void sayHello() {
System.out.println("Hello from UserService");
}
}
第二章:依赖注入(DI)的艺术与陷阱
依赖注入是 Spring 的灵魂。但灵魂这东西,有时候挺难捉摸的。最常见的两种注入方式是 构造器注入 和 字段注入。
2.1 字段注入 vs 构造器注入:一场关于测试的争论
很多教程喜欢用 @Autowired 直接写在字段上,因为代码短,看起来爽。
@RestController
public class UserController {
@Autowired
private UserService userService; // 字段注入
@GetMapping("/hello")
public String hello() {
return userService.sayHello();
}
}
这里有一个巨大的坑: 这种写法让你很难进行单元测试。因为 userService 是私有且由框架注入的,你在写 JUnit 测试时,很难在不启动整个 Spring 上下文的情况下替换掉 userService 为一个 Mock 对象。
专家建议: 使用 构造器注入。这不仅是为了可测试性,更是为了确保依赖的不可变性和非空性。
@RestController
public class UserController {
private final UserService userService; // final 关键字,强调不可变
// 构造器注入
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/hello")
public String hello() {
return userService.sayHello();
}
}
为什么这样更好?
- 显式依赖:一眼就能看出这个 Controller 依赖于
UserService。 - 易于测试:在单元测试中,你可以直接
new UserController(mockUserService),无需启动 Spring。 - 避免循环依赖:虽然 Spring 能解决一些循环依赖,但构造器注入会在启动时立即暴露循环依赖问题,让你早点发现架构设计缺陷,而不是等到运行时才崩溃。
2.2 循环依赖:死锁的前奏
假设 A 依赖 B,B 又依赖 A。
@Service
public class ServiceA {
private ServiceB serviceB;
// 构造器注入会导致启动失败!
public ServiceA(ServiceB serviceB) {
this.serviceB = serviceB;
}
}
@Service
public class ServiceB {
private ServiceA serviceA;
public ServiceB(ServiceA serviceA) {
this.serviceA = serviceA;
}
}
当你启动应用时,你会看到:
The dependencies of some of the beans in the application context form a cycle
为什么会这样? Spring 创建 Bean 的过程是这样的:
- 创建
ServiceA。 - 发现
ServiceA需要ServiceB。 - 暂停
ServiceA的创建,去创建ServiceB。 - 发现
ServiceB需要ServiceA。 - 回到步骤 1… 无限递归,直到栈溢出或检测到循环。
避坑指南:
- 重构代码:这是最根本的解决办法。提取公共接口,打破耦合。
- 慎用
@Lazy:你可以在其中一个构造器参数上加@Lazy,让 Spring 先创建一个代理对象注入进去,等到真正调用时才去实例化真正的对象。但这只是掩耳盗铃,架构上依然有问题。 - 字段注入可以解决吗? 对于单例 Bean,Spring 的三级缓存机制确实可以通过字段注入(Setter 注入)来解决简单的循环依赖。但这不代表你应该这样做。构造器注入 + 循环依赖报错 是一种保护机制,它在告诉你:“你的设计有问题”。
第三章:面向切面编程(AOP):魔法背后的逻辑
如果说 IoC/DI 是 Spring 的骨架,那么 AOP 就是它的神经系统和肌肉记忆。你想在方法执行前记录日志?想在事务提交前检查权限?想在异常发生时统一处理?这些横切关注点(Cross-cutting Concerns)最适合用 AOP 来做。
3.1 什么是切面?
想象你在拍电影。导演(业务逻辑)负责演戏,但你需要一个场记板在开机前打一下(前置通知),还需要在关机后放一段音乐(后置通知)。这些“打板”和“放音乐”的动作,不应该写在导演的剧本里,而应该由专门的团队(切面)来完成。
在 Spring 中,切面通常是一个带有 @Aspect 注解的类。
3.2 实战:统一日志切面
让我们写一个真实的例子。假设我们要记录所有 Controller 层的请求日志。
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class LogAspect {
private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
// 定义切入点:所有 com.example.controller 包下的公共方法
@Around("execution(* com.example.controller..*(..))")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
// 记录进入日志
log.info("Start method: {}", joinPoint.getSignature().toShortString());
try {
// 执行目标方法
Object proceed = joinPoint.proceed();
// 记录耗时
long executionTime = System.currentTimeMillis() - start;
log.info("Method {} executed in {} ms", joinPoint.getSignature().toShortString(), executionTime);
return proceed;
} catch (Throwable ex) {
log.error("Error in method {}: {}", joinPoint.getSignature().toShortString(), ex.getMessage());
throw ex; // 重新抛出异常,保证事务回滚等机制正常工作
}
}
}
关键点解析:
@Aspect:告诉 Spring 这是一个切面类。@Component:让 Spring 容器管理这个切面 Bean。@Around:这是一种环绕通知。它可以在方法执行前后都进行操作。相比于@Before和@After,@Around功能最强,但也最需要注意性能和对原始返回值的影响。ProceedingJoinPoint.proceed():这是关键。如果不调用这个方法,目标方法就不会执行。这就像是你拿着遥控器,按下了播放键。
3.3 AOP 的常见报错:为什么我的切面没生效?
这是新手最容易遇到的坑之一。你写了完美的切面,日志也没打印出来。为什么?
坑 1:自调用问题(Self-invocation)
@Service
public class OrderService {
public void createOrder() {
processPayment(); // 内部调用
}
@Transactional // 假设这里有事务切面
public void processPayment() {
// 支付逻辑
}
}
如果你在 createOrder 内部直接调用 processPayment,@Transactional 切面不会生效!
原因: Spring AOP 是基于代理(Proxy)的。当外部调用 createOrder 时,进入的是代理对象,代理对象会检查是否有切面,然后决定是否开启事务。但是,processPayment 是在 OrderService 内部被调用的,此时 this.processPayment() 指向的是目标对象本身,而不是代理对象。因此,切面逻辑被绕过了。
解决方案:
- 提取方法:将
processPayment提取到另一个 Service 中。 - 注入自己:在类中注入自己的代理对象(虽然有点 hack,但有效)。
- 使用 AspectJ:AspectJ 支持编译时织入或加载时织入,可以拦截内部调用,但配置复杂,一般 Spring Boot 项目不推荐首选。
坑 2:访问权限问题
如果你的切面表达式匹配的是 private 方法,默认情况下 Spring AOP 是无法织入的。Spring AOP 默认只对 public 方法生效。如果你非要拦截私有方法,需要调整 AbstractAutoProxyCreator 的配置,但这通常是不推荐的,因为这破坏了封装性。
坑 3:代理模式的选择:JDK 动态代理 vs CGLIB
Spring Boot 2.x 之后,默认使用 CGLIB 来创建代理,除非你的 Bean 实现了接口且你强制指定使用 JDK 代理。
- JDK 动态代理:只能代理接口。如果被代理的对象没有实现任何接口,JDK 代理无法工作。
- CGLIB:通过继承目标类来创建代理子类。它可以代理类,但如果目标类被
final修饰,CGLIB 也无法工作。
常见报错:
org.springframework.aop.framework.AopConfigException: Cannot proxy target class because CGLIB2 is not available.
或者更常见的:
java.lang.IllegalArgumentException: Cannot subclass final class com.example.FinalClass
解决方案:
确保你的目标类没有被 final 修饰,或者确保它实现了接口(如果使用 JDK 代理)。在大多数 Spring Boot 应用中,保持默认设置即可,但要知道这个区别有助于排查奇怪的代理问题。
第四章:企业级实战中的“深水区”
从 HelloWorld 到企业级应用,中间隔着无数个“生产事故”。让我们聊聊几个真实的场景。
4.1 事务管理的失效瞬间
事务(Transaction)是企业级应用的基石。@Transactional 注解看似简单,实则暗藏玄机。
场景: 你在一个 Service 方法上加了 @Transactional,但数据并没有回滚。
原因分析:
- 异常类型不对:Spring 默认只在捕获到
RuntimeException和Error时回滚。如果你抛出了一个受检异常(Checked Exception,如IOException或自定义的extends Exception),事务不会回滚,除非你显式配置rollbackFor。@Transactional(rollbackFor = Exception.class) // 显式指定所有异常都回滚 public void saveData() throws Exception { // ... } - 同类内部调用:如前所述,自调用会导致代理失效,事务也就失效了。
- 数据库引擎不支持:如果你用的是 MySQL 的 MyISAM 引擎,它根本不支持事务。请确保使用 InnoDB。
4.2 多数据源配置:如何优雅地切换?
很多老系统需要同时读写 MySQL 和 Oracle,或者读写主库和从库。
思路:
- 定义多个 DataSource Bean。
- 定义多个 EntityManagerFactory 或 SqlSessionFactory。
- 使用
@Primary指定默认数据源。 - 通过 AOP 或自定义注解(如
@DataSource("slave"))在运行时切换数据源。
简易代码示例:
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.primary")
public DataSource primaryDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource.secondary")
public DataSource secondaryDataSource() {
return DataSourceBuilder.create().build();
}
// 其他配置...
}
配合一个 ThreadLocal 存储当前使用的数据源 Key,然后在 AOP 中根据注解切换 AbstractRoutingDataSource 的 targetDataSource。这比硬编码要灵活得多。
4.3 性能调优:慢查询与 N+1 问题
Spring Data JPA 非常方便,但容易引发 N+1 查询问题。
现象: 你查询一个 User 列表,每个 User 关联一个 Address。JPA 默认懒加载(Lazy Loading)。
- 执行一条 SQL 查询所有 User。
- 遍历 User 列表,每次访问
user.getAddress()时,因为懒加载,又执行一条 SQL 查询 Address。 - 如果有 100 个 User,就是 1 + 100 = 101 条 SQL。
解决方案:
- @EntityGraph:在 Repository 方法上指定 EntityGraph,预加载关联实体。
@EntityGraph(attributePaths = {"address"}) List<User> findAll(); - JPQL JOIN FETCH:在查询语句中显式 JOIN。
@Query("SELECT u FROM User u LEFT JOIN FETCH u.address") List<User> findAllWithAddress();
第五章:调试技巧——当报错信息像天书时
当你看到 StackOverflowError 或 OutOfMemoryError 时,不要慌。
启用 DEBUG 日志: 在
application.properties中添加:logging.level.org.springframework=DEBUG logging.level.com.your.package=TRACE这会打印出 Spring 容器初始化的每一步,包括 Bean 的创建顺序、依赖注入过程。你能清晰地看到哪个 Bean 在什么时候失败了。
使用 Actuator: 引入
spring-boot-starter-actuator,访问/actuator/beans端点。你可以看到容器中所有的 Bean 及其依赖关系图。这对于排查循环依赖和 Bean 覆盖问题极其有用。IDEA 的 Debug 模式: 在
@Bean创建的方法或构造函数打断点。观察调用栈。看看是谁在调用你的 Bean 构造函数。很多时候,你会发现是因为某个配置类错误地引入了某个 Bean,导致无限递归创建。
结语:保持敬畏,持续学习
Spring 框架之所以庞大,是因为它试图解决软件工程中最核心的问题:解耦和复用。
从 Hello World 到企业级应用,你走过的每一步都在加深你对“控制反转”和“面向切面”的理解。记住,框架是工具,思想才是核心。不要因为熟练使用了注解而忘记了底层发生了什么。
当你再次遇到 BeanCreationException 时,不要只是复制粘贴 StackOverflow 的答案。试着去理解:Spring 容器在创建这个 Bean 时,到底缺了什么?是类路径下少了一个 jar 包?还是注解漏加了?亦或是循环依赖的结构不合理?
这种探究精神,才是让你从“Spring 使用者”蜕变为“Spring 专家”的关键。
现在,去吧,打开你的 IDE,运行那个绿色的三角形。愿你的日志里没有 ERROR,愿你的事务永远回滚正确,愿你的系统高可用、高性能。
如果还有疑问,随时回来。毕竟,即使是专家,也曾经是个对着控制台发呆的新手。
