16.Spring核心IoC与AOP

Spring 核心入门:用 IoC 管理对象,用 AOP 添加公共功能
学过类和对象后,我们已经知道:要使用一个对象,通常先用 new 创建它,再调用它的方法。那么,为什么学习 Spring 时,还要专门学习“管理对象”?
可以从学生管理业务想起。显示学生介绍,需要一个服务对象;查询学生,又需要访问数据库的对象。对象之间会互相依赖,如果每个类都在内部创建自己需要的对象,那么更换数据库访问方式时,就得回到这些类中逐个修改。我们希望业务类只说明“我要用谁”,由一个统一的地方负责准备和交付对象。
对象准备好了,还有另一种重复需要处理。查询学生、修改学生、删除学生都可能要记日志。如果每个方法都写一遍相同的日志,修改日志格式时又得到处找。我们还希望把这类公共处理集中起来,在需要的方法调用中执行。
Spring 的两项核心能力正好对应这两个问题:
- IoC(控制反转):把对象的创建和组装交给容器。
- AOP(面向切面编程):把日志、事务等公共功能集中起来,在指定的方法调用中执行。
下面先用 Student 看清对象怎样交给容器,再让一个服务使用它;理解了对象之间怎样配合,就继续用计算器 Cal 观察 AOP 如何添加日志。两个例子都从普通 Java 代码开始,逐步引入 Spring 的写法。
示例采用 Java 17、Spring Framework 6.2.6。先在普通 Maven 项目中运行,最后再说明放到 Spring Boot 项目时,哪些配置会由框架完成。
1. IoC:把对象交给容器管理
1.1 是什么,为什么需要它
先看最熟悉的创建方式:
java
Student student = new Student();
student.setName("张三");
这段代码当然能用。它说明了普通 Java 程序的做法:谁需要 Student,谁就创建它,并准备姓名等属性。
如果这里只创建一个学生,看不出什么麻烦;但当一个服务还需要其他服务、数据库访问对象时,创建和组装过程就会越来越多。Spring 提供了一个集中管理对象的地方,叫 IoC 容器。我们先告诉容器需要准备什么对象,再向它取用。
例如,容器已经根据配置创建了名为 student 的对象,调用处就可以这样获取。这里先看写法,容器的配置和完整运行代码马上会给出:
java
// student 已经由容器创建并设置好了属性。
Student student = context.getBean("student", Student.class);
交给 Spring 管理的对象叫 Bean。Bean 不是某种特殊的 Java 语法,前面的普通 Student 对象也能成为 Bean,关键是它有没有交给容器管理。
ApplicationContext 是 Spring 提供的容器接口,完整名称是 org.springframework.context.ApplicationContext。后面会使用它的具体实现读取 XML 或 Java 配置,再通过 getBean(...) 获取 Bean。
所谓“控制反转”,说的就是创建、组装对象的责任发生了变化:以前由调用者自己处理,现在主要交给容器处理。它是一种设计思想,不对应某一个固定注解,也不等同于某一种设计模式。
这里的重点并不是把每一句 new 都删掉。例如使用 Java 配置时,我们仍然会在创建方法里写 new,但业务调用处不再承担这项工作。等会儿把 Student 交给 StudentService 使用时,就能进一步看清这个区别。
1.2 配置为什么能变成对象
知道了“向容器取对象”,还需要弄清楚对象从哪里来。Spring 并不会自动认识我们写的每一个类,需要先读取配置,知道哪些类应当交给它管理。
把下面几步连起来看,就能理解容器的基本工作:
- 读取 XML、注解或 Java 配置,找到需要管理的 Bean。
- 创建对象,并把它需要的属性、其他 Bean 传进去。
- 完成初始化,向调用者提供可以使用的对象。
- 在适当的时候执行销毁回调,释放需要释放的资源。
例如配置中写出了 Student 的完整类名,Spring 就可以找到这个类,再通过反射等机制调用构造方法。配置中还写了 age 的值,容器就把这个值设置到对象中。对于 @Bean 方法,创建逻辑也可以由我们直接写在方法里。
因此,不是加上一个注解,对象就会自己出现;注解、XML 和 Java 配置都需要被容器读取,之后才有创建和组装过程。
把对象需要的值或其他对象从外部传进去,叫 依赖注入(DI,Dependency Injection)。例如给 Student 设置年龄,或者把 Student 传给 StudentService,都在演示注入。IoC 关注创建和管理的责任交给谁,DI 关注对象需要的东西怎样交到它手里,两者放在同一个过程里理解即可。
1.3 准备依赖和 Student
先在 Maven 项目中加入 Spring Context:
xml
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>6.2.6</version>
</dependency>
依赖准备好以后,先定义一个普通的 Student,字段仍然使用编号、姓名和年龄。暂时不加 Spring 注解,让我们先看清:一个普通 Java 类怎样通过配置成为 Bean。
这里直接写出 getter 和 setter。稍后 XML 中的 property 会调用 setter 设置属性,而获取对象后,我们通过 getter 读取配置结果。
java
package com.yigalaxy.spring001.entity;
public class Student {
private Integer id;
private String name;
private Integer age;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
如果项目已经使用 Lombok,也可以用 @Data 生成这些访问方法,并生成 toString、equals、hashCode 等方法。不过这属于 Lombok 提供的代码生成,与 Spring 管理对象是两件事;使用 @Data 时,还需要配置 Lombok 依赖和注解处理。
有了这个类,接下来分别用 XML、组件注解和 Java 配置创建对象。三种方式是同一需求的不同写法,每次只运行当前方式的入口,不必把三套配置一起加载。
1.4 第一种:通过 XML 声明 Bean
先从 XML 开始,因为它把“创建哪个类、设置哪些属性”写得很直观。维护已有的 Spring 项目时,也经常会遇到这种配置。
在 src/main/resources 下创建 spring.xml:
xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="student" class="com.yigalaxy.spring001.entity.Student">
<property name="id" value="1"/>
<property name="name" value="张三"/>
<property name="age" value="18"/>
</bean>
</beans>
先看外面的 bean:id="student" 给容器中的对象取了一个名字,class 告诉容器应当创建哪个类,所以这里要写包括包名在内的完整类名。
再看里面的 property。name="age" 是属性名,对应 setAge(...),不是要求 Spring 直接访问私有字段。Spring 读取 value="18" 后,会把字符串转换为需要的 Integer,再调用 setter。因此,如果属性名拼错,或者没有对应 setter,就不能按这种方式完成注入。
XML 只是配置,还需要一个容器来读取它。把下面的入口运行起来,就能取到已经设置好属性的对象:
java
package com.yigalaxy.spring001;
import com.yigalaxy.spring001.entity.Student;
import org.springframework.context.support.ClassPathXmlApplicationContext;
public class Test {
public static void main(String[] args) {
// 从类路径读取 XML,并创建其中声明的 Bean。
try (ClassPathXmlApplicationContext context =
new ClassPathXmlApplicationContext("spring.xml")) {
Student student = context.getBean("student", Student.class);
System.out.println(student.getName() + "," + student.getAge() + "岁");
}
}
}
输出:
text
张三,18岁
运行时,ClassPathXmlApplicationContext 先从类路径读取 spring.xml,按照配置创建 Student 并调用 setter,然后 getBean 才把准备好的对象交给我们。
getBean("student", Student.class) 的第一个参数按名称选择 Bean,第二个参数说明期望取得的类型。这里只有一个 Student 时,也可以写 getBean(Student.class),直接按类型获取;如果同类型 Bean 不止一个,就需要进一步说明使用谁。
这里的 try (...) 会在结束时关闭容器,让需要销毁处理的 Bean 有机会释放资源。例如数据库连接池会有自己的关闭逻辑,普通 Student 则没有特殊资源需要清理;关闭容器不等于 Java 对象会立即被垃圾回收。
XML 把配置和 Java 代码分开,同一个类也能用不同 Bean 名称配置多个对象。不过,类名和属性名写在字符串中,重命名类或 setter 后,也要记得检查对应 XML。
1.5 第二种:通过注解和组件扫描声明 Bean
XML 已经让我们看到了容器的工作过程,但业务类多起来时,逐个写 bean 会比较繁琐。如果这些类是自己编写的,可以直接在类上加 @Component,表示“这个类需要作为组件被发现”,再让容器扫描它所在的包。
在前面的 Student 中增加注解,字段和访问方法仍然保留:
java
// 放在 Student 所在文件的 import 区域。
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
类和字段调整为下面这样,其余 getter、setter 继续使用前面的实现:
java
@Component
public class Student {
@Value("1")
private Integer id;
@Value("张三")
private String name;
@Value("18")
private Integer age;
// 此处保留前面已经定义的 getter 和 setter。
}
两个注解的分工要分开看:@Component 负责声明组件,@Value 负责提供字段的值。例如,@Value("18") 让容器把年龄设置为 18,并不是 Java 自己执行了字段赋值。
这里暂时把值写在注解里,便于和 XML 结果对照。实际项目更常用 @Value 读取地址、开关等配置;学生数据通常来自数据库,不会把每个学生都固定注册成 Bean。
类上有了注解,还差“让容器找到它”这一步。把前面的 Test 替换为下面的入口,指定要扫描的包:
java
package com.yigalaxy.spring001;
import com.yigalaxy.spring001.entity.Student;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class Test {
public static void main(String[] args) {
// 扫描这个包及其子包,找到带组件注解的类。
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(
"com.yigalaxy.spring001.entity")) {
Student student = context.getBean(Student.class);
System.out.println(student.getName());
}
}
}
输出:
text
张三
这次没有读取 XML。AnnotationConfigApplicationContext 扫描指定包及其子包,找到 @Component,再创建对象并处理字段上的 @Value。如果包名写错,扫描根本没有覆盖 Student,即使类上有注解,也不能从这个容器中取得它。
自己编写的类不只有学生信息,还会有业务服务、数据库访问类和控制器。Spring 为这些用途提供了更具体的组件注解:
| 注解 | 通常放在哪里 |
|---|---|
@Component |
通用组件 |
@Service |
处理业务的服务类 |
@Repository |
访问数据库的类 |
@Controller |
返回页面或处理 Web 请求的控制器 |
@RestController |
主要返回 JSON 等响应数据的控制器 |
例如处理学生查询流程的类适合标记 @Service,执行数据库访问的类适合标记 @Repository。它们都可以被组件扫描发现,名称也向阅读代码的人表达了这一层的职责。
这些注解并不总是只有名字不同。例如,@Repository 可以配合异常转换处理,@RestController 还包含将返回值写入响应体的行为。选择注解时应当结合类的用途,而不是随意替换。
没有指定名称时,默认 Bean 名通常是类名首字母小写,所以这里的 Student 对应 student。如果想自己命名,可以将类上的注解改为 @Component("myStudent"),随后按名称获取时也要使用 myStudent。
以连续大写字母开头的类名有特殊规则,例如 URLClient 的默认名称会保持 URLClient。需要固定名称时,直接显式指定更清楚。
1.6 第三种:通过 Java 配置类声明 Bean
注解扫描适合自己编写的业务类。如果对象来自第三方库,我们通常不能去修改它的源码、添加 @Component。还有些对象需要先设置几个参数,再交给程序使用。这时就可以写一个 Java 方法负责创建对象,并用 @Bean 把返回值交给容器。
本节使用最初那个没有组件注解的 Student,避免和前一节重复注册:
java
package com.yigalaxy.spring001.configuration;
import com.yigalaxy.spring001.entity.Student;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class StudentConfiguration {
@Bean
public Student student() {
Student student = new Student();
student.setId(2);
student.setName("李四");
student.setAge(23);
// 返回值交给容器管理,调用者从容器取得它。
return student;
}
}
先看方法体,它就是我们熟悉的 Java 代码:创建 Student,设置属性,再返回对象。不同的地方在方法外面:@Configuration 把类声明为配置类,@Bean 告诉容器,这个方法负责提供一个 Bean。
默认 Bean 名来自方法名,所以这里仍然叫 student。也可以使用 @Bean("studentBean") 显式改名。
光写出这个方法还不够,也要让容器读取配置类。这次不传 XML 名称或扫描包名,而是传入 StudentConfiguration.class:
java
package com.yigalaxy.spring001;
import com.yigalaxy.spring001.configuration.StudentConfiguration;
import com.yigalaxy.spring001.entity.Student;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class Test {
public static void main(String[] args) {
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(StudentConfiguration.class)) {
Student student = context.getBean(Student.class);
System.out.println(student.getName() + "," + student.getAge() + "岁");
}
}
}
输出:
text
李四,23岁
容器调用 student() 方法,管理它返回的对象,再通过 getBean 提供给调用者。我们虽然在配置方法中写了 new Student(),但业务调用处不用再关心对象具体怎样初始化。
数据库连接池、外部接口客户端等第三方对象,也常通过这种方式配置。把地址、账号或超时时间设置好,再将对象交给容器,其他业务组件就可以使用它。
1.7 怎样把一个 Bean 注入另一个 Bean
三种方式都能创建 Student 了,但如果只是换一种方式拿到学生对象,还没有充分体现容器的作用。接着看更常见的情况:一个对象需要另一个对象才能工作。
继续使用 Java 配置版的 Student。现在增加 StudentService,让它负责组织学生介绍。它需要读取学生的姓名和年龄,因此依赖一个 Student:
java
package com.yigalaxy.spring001.service;
import com.yigalaxy.spring001.entity.Student;
public class StudentService {
private final Student student;
// 先说明需要什么,让外部负责把对象传进来。
public StudentService(Student student) {
this.student = student;
}
public String introduction() {
return student.getName() + "," + student.getAge() + "岁";
}
}
先不使用 Spring,也可以通过构造方法把对象传进去:
java
Student student = new Student();
student.setName("李四");
student.setAge(23);
// 服务使用外面传进来的学生,不在内部固定创建一个学生。
StudentService service = new StudentService(student);
System.out.println(service.introduction()); // 李四,23岁
这里已经使用了构造器注入,依赖注入并不要求必须有 Spring。服务只负责组织介绍,创建学生和创建服务则由外部完成。
如果交给 Spring,连上面这段装配工作也可以放进配置中。在 StudentConfiguration 中导入 StudentService,然后增加下面的方法,原来的 student() 方法继续保留:
java
@Bean
public StudentService studentService(Student student) {
// 方法参数会由容器提供,使用已经注册的 Student Bean。
return new StudentService(student);
}
这里的方法参数不是调用者自己填写的。Spring 发现需要一个 Student,就从当前容器找到前面 student() 提供的 Bean,再把它传给这个方法。
在前面加载 StudentConfiguration 的入口中,把 try 内的取用语句改为:
java
StudentService service = context.getBean(StudentService.class);
System.out.println(service.introduction()); // 李四,23岁
运行时可以按这个顺序理解:容器先准备 Student,再把它交给 studentService(...),创建 StudentService,最后入口拿到已经能够工作的服务。服务内部只使用依赖,不负责决定该从哪里创建依赖。
如果项目采用组件扫描,也可以让容器直接通过构造器创建服务。下面是 StudentService 的另一种写法,与前面的 @Bean 注册方式分别使用:
java
package com.yigalaxy.spring001.service;
import com.yigalaxy.spring001.entity.Student;
import org.springframework.stereotype.Service;
@Service
public class StudentService {
private final Student student;
// 只有一个构造器时,Spring 会据此查找并注入依赖。
public StudentService(Student student) {
this.student = student;
}
public String introduction() {
return student.getName() + "," + student.getAge() + "岁";
}
}
这一版需要容器能够找到服务和学生。可以先加载原来的 StudentConfiguration,再扫描服务包,但应当移除配置里已经添加的 studentService(...) 方法,避免重复声明服务:
java
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext()) {
context.register(StudentConfiguration.class);
context.scan("com.yigalaxy.spring001.service");
// 先完成注册、扫描配置,再启动容器处理它们。
context.refresh();
StudentService service = context.getBean(StudentService.class);
System.out.println(service.introduction()); // 李四,23岁
}
StudentService 只有一个构造器,因此无需额外标记 @Autowired。容器扫描到它以后,会按构造器参数类型寻找 Student。
除了构造器注入,Spring 还支持 Setter 注入。前面 XML 的 property 就是通过 setter 设置属性;如果要传入另一个 Bean,则使用 ref 指向它的名称。字段注入则常通过 @Autowired 标记字段。这几种方式的共同点,都是把依赖从外面交给对象;必需的依赖通常优先使用构造器,让人一眼就能看到创建对象需要什么。
这里用学生对象,是为了看清对象之间怎样装配。真实学生管理业务通常把服务和数据库访问组件交给容器,由它们查询数据库、返回学生数据,不会把全班每个学生都当作固定 Bean。
1.8 三种配置怎样选择
回头看这三种写法,它们解决的是同一个问题:告诉容器应当管理哪些对象,以及怎样准备这些对象。区别在于把这些信息放在哪里。
| 方式 | 适合的用途 |
|---|---|
| XML | 维护已有项目,集中查看对象和属性配置 |
| 注解扫描 | 注册自己编写的业务组件 |
| Java 配置 | 配置第三方对象,明确编写创建和装配逻辑 |
项目里可以混合使用。例如自己写的服务通过 @Service 注册,第三方连接池通过 @Bean 注册,都由同一个容器管理。混合使用不等于把同一个组件反复声明,组件扫描和 Java 配置的注册范围仍然需要安排清楚。
1.9 同类型有多个 Bean,容器该选谁
前面只配置了一个 Student,按类型获取没有歧义。如果容器里同时配置了两个学生,单写 getBean(Student.class),容器就不知道我们究竟想要哪一个。
这一节回到用 @Bean 方法注册 StudentService 的版本,服务类本身不加 @Service,也不扫描服务包。在保留 student() 的基础上,向配置类增加另一个方法:
java
@Bean
public Student guestStudent() {
Student student = new Student();
student.setId(3);
student.setName("王五");
student.setAge(20);
return student;
}
现在容器里有 student 和 guestStudent,类型都是 Student。主动取用时,可以用名称明确选择:
java
Student student = context.getBean("student", Student.class);
Student guest = context.getBean("guestStudent", Student.class);
System.out.println(student.getName()); // 李四
System.out.println(guest.getName()); // 王五
如果要注入服务,也应当表达相同的选择。导入 org.springframework.beans.factory.annotation.Qualifier,把原来的 studentService(...) 方法替换为:
java
@Bean
public StudentService studentService(
@Qualifier("student") Student student) {
// 明确选名为 student 的 Bean,不让选择依赖参数名等推断。
return new StudentService(student);
}
@Qualifier("student") 表示从符合类型的候选中,选择这个名称对应的对象。因此服务继续使用李四;如果改为 @Qualifier("guestStudent"),服务介绍的就是王五。
还有一种需求:大部分地方都使用某个默认对象,少数地方再明确指定。可以给默认 Bean 的创建方法加上 @Primary。它表示“同类型存在多个候选时,优先选这个”,而 @Qualifier 适合在某次注入时明确选谁。它们解决的是候选选择问题,不会替我们解决重名注册。
要分清两种问题:两个 Bean 名称不同、类型相同,需要选择候选;两个 Bean 名称相同,则涉及重复注册和是否允许覆盖。是否允许覆盖与容器配置有关,不能靠 @Primary 或 @Qualifier 消除重名问题。
1.10 取两次 Bean,是不是就创建了两个对象
对象交给容器后,还需要知道它会不会被重复使用。Spring Bean 默认通常采用单例作用域:对于同一个容器中的同一个 Bean 定义,多次获取通常拿到同一个对象。
回到只有一个学生的配置,在已经打开的容器中执行:
java
Student first = context.getBean("student", Student.class);
Student second = context.getBean("student", Student.class);
// 比较引用,判断是不是同一个对象。
System.out.println(first == second); // true
这里说的单例有范围:是这个容器中、这个 Bean 定义对应的实例。两个不同名称的 Bean 即使类型相同,也可以各自拥有实例;不同容器也不是天然共享同一个对象。
这与开发有关。例如很多请求会共用同一个学生服务 Bean,服务字段里就不应随意保存“这一次请求的学生”或“当前用户”。这类数据更适合放在方法参数和局部变量中,否则不同请求可能互相覆盖状态。
2. AOP:给多个方法添加公共功能
2.1 是什么,什么业务会用到
有了 IoC,我们已经能把服务和它依赖的对象准备好。接着考虑服务方法里经常重复的事情:查询学生要记录日志,修改学生也要记录日志;如果每个方法都写同样的语句,以后调整格式就得逐个修改。
AOP 就用来组织这类跨越多个方法的公共处理。我们把日志集中写在一个地方,再说明哪些方法需要执行它,Spring 就可以在这些方法被调用时插入相应处理。
这个集中放置公共处理的组件叫 切面,例如日志切面。至于它怎样在方法调用中生效,下面会从一个计算器开始,先观察没有日志时的行为,再一步步接上切面。
常见用途包括日志记录、耗时统计、事务管理、权限检查以及统一的异常观察或处理。具体功能仍然需要自己实现或使用框架提供的组件,不是加上 AOP 就自动具备所有能力。
我们平时用类和对象组织学生、订单等业务,这属于 OOP(面向对象编程)的思路。AOP 进一步把这些业务都需要的日志、计时等处理抽出来,两者可以一起使用。
例如订单方法只负责下单,事务组件负责发生异常时是否回滚;权限组件负责调用者是否有权执行。这些功能也可能改变调用结果或是否允许执行,因此要根据具体需求配置,不能把所有 AOP 处理都理解成单纯“多打一行日志”。
2.2 先准备没有日志的计算器
先保留加、减、乘、除这四个简单操作。接口说明计算器能做什么:
java
package com.yigalaxy.spring001.aop;
public interface Cal {
int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);
int div(int a, int b);
}
实现类只写计算逻辑:
java
package com.yigalaxy.spring001.aop;
import org.springframework.stereotype.Component;
@Component
public class CalImpl implements Cal {
@Override
public int add(int a, int b) {
return a + b;
}
@Override
public int sub(int a, int b) {
return a - b;
}
@Override
public int mul(int a, int b) {
return a * b;
}
@Override
public int div(int a, int b) {
return a / b;
}
}
先不用 Spring,直接创建计算器并调用,确认普通方法的行为:
java
Cal cal = new CalImpl();
System.out.println(cal.add(1, 2)); // 3
结果是 3,没有其他输出。现在要求计算前打印参数,计算成功后打印结果。最直接的写法,是把 add 临时替换成下面这样:
java
@Override
public int add(int a, int b) {
System.out.println("add 的参数是:[" + a + ", " + b + "]");
int result = a + b;
System.out.println("add 的结果是:" + result);
return result;
}
这段代码同样能运行,但加法里混进了日志。如果减法、乘法、除法也需要,就要再复制三遍。以后增加方法,还要记得继续复制。
接下来把 add 恢复为只做加法的版本,四个方法都保留最初的计算逻辑,把这份重复日志集中到切面里。
2.3 用这个例子认识四个术语
在写注解之前,先把几个名字对应到刚才的代码。这样看到术语时,脑子里就能有具体的位置:
- 连接点(Join Point):可以添加公共处理的位置。Spring AOP 中是方法执行,例如
add(1, 2)。 - 切点(Pointcut):选择哪些方法,例如选择
CalImpl的所有公共方法。 - 通知(Advice):具体做什么、何时做,例如调用前打印参数。
- 切面(Aspect):把切点和通知放在一起的公共功能,例如日志切面。
例如,add(1, 2) 是一次方法执行;我们选择“计算器的全部公共方法”,这就是切点负责的事;调用前打印参数,这是前置通知;把选择规则和日志动作放在一起,就组成了日志切面。
因此写切面时,可以先问两个具体问题:要为哪些方法添加功能?要在它们执行前、执行后,还是出异常时做什么?下面的注解就分别表达这些信息。
2.4 编写日志切面
在前面的 spring-context 之外,再加入 AspectJ 支持:
xml
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.24</version>
</dependency>
aspectjweaver 提供这里需要的 AspectJ 注解和表达式支持,spring-context 则带来普通 Spring 容器所需的配套模块。本例仍使用 Spring AOP 在运行时创建代理,不是启用了完整的 AspectJ 编织。
先做两件事:计算前打印参数,正常返回后打印结果。把它们写进 LoggerAspect:
java
package com.yigalaxy.spring001.aop;
import java.util.Arrays;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class LoggerAspect {
private static final String CAL_METHODS =
"execution(public * com.yigalaxy.spring001.aop.CalImpl.*(..))";
@Before(CAL_METHODS)
public void before(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
// 从本次调用中取出参数,不改变参数本身。
System.out.println(methodName + " 的参数是:"
+ Arrays.toString(joinPoint.getArgs()));
}
@AfterReturning(value = CAL_METHODS, returning = "result")
public void afterReturning(JoinPoint joinPoint, Object result) {
String methodName = joinPoint.getSignature().getName();
// 只有目标方法正常返回时,才打印返回结果。
System.out.println(methodName + " 的结果是:" + result);
}
}
先看类上的两个注解。@Aspect 表明这个类描述切面规则,@Component 则让它成为能被扫描到的 Bean。只认识切面规则而没有把对象交给容器,本例的自动装配就无法完成。
再看两个方法。@Before 表示在选中的目标方法执行前调用 before;@AfterReturning 表示只在目标方法正常返回后执行,returning = "result" 把计算结果交给名为 result 的参数。
JoinPoint 是 AspectJ 提供的调用信息对象,由框架传给通知方法,不用我们自己创建。对 add(1, 2) 这次调用来说,getSignature().getName() 得到 add,getArgs() 得到参数数组 [1, 2]。Arrays.toString(...) 只是把这个数组变成适合打印的文字。
这些通知负责做什么已经清楚了,还需要看“选中哪些方法”。CAL_METHODS 保存的字符串就是切点表达式,可以拆开读:
text
execution(public * com.yigalaxy.spring001.aop.CalImpl.*(..))
修饰符 返回类型 目标类 方法 参数
execution(...) 表示按方法执行进行匹配。这里限定为 public,返回类型处的 * 表示任意返回类型,完整类名限定到 CalImpl,方法名处的 * 表示它的任意方法,(..) 则表示参数数量和类型不限。
因此,加、减、乘、除都会匹配。以后只想给加法添加日志,可以把方法名处的 * 换成 add;想匹配业务包及子包中的方法,则可以使用 execution(* com.example.service..*.*(..))。包名后的 .. 表示包含子包,后面的类名和方法名各有一个 *。
切点决定了哪些业务会受到公共处理影响,实际开发应先明确范围。入门时只匹配这个计算器,方便我们核对每一次输出到底来自哪里。
2.5 启用代理,并从容器调用
创建配置类,扫描计算器和切面所在的包:
java
package com.yigalaxy.spring001.configuration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@Configuration
@ComponentScan("com.yigalaxy.spring001.aop")
@EnableAspectJAutoProxy
public class AopConfig {
}
@ComponentScan 让容器找到计算器和日志切面;@EnableAspectJAutoProxy 再让容器为匹配的 Bean 准备代理。代理同样能通过 Cal 接口调用,但会在调用实际计算方法的前后执行通知。
因此,入口也要改用容器提供的 Cal。如果还像前面那样自己 new CalImpl(),调用就无法经过这层代理:
java
package com.yigalaxy.spring001;
import com.yigalaxy.spring001.aop.Cal;
import com.yigalaxy.spring001.configuration.AopConfig;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class Test {
public static void main(String[] args) {
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(AopConfig.class)) {
// 取得容器提供的代理,通过接口发起调用。
Cal cal = context.getBean(Cal.class);
System.out.println(cal.add(1, 2));
}
}
}
输出:
text
add 的参数是:[1, 2]
add 的结果是:3
3
把三行输出与执行过程对照:第一行来自前置通知,随后计算器执行 1 + 2,第二行来自返回通知,最后一行才是入口打印返回值。计算结果仍然是 3,只是调用过程中增加了日志。
在同一个 try 中继续调用其余方法,也能看到相同格式的日志:
java
System.out.println(cal.sub(5, 3)); // 结果是 2
System.out.println(cal.mul(2, 3)); // 结果是 6
System.out.println(cal.div(6, 3)); // 结果是 2
这三个方法都匹配同一个切点,所以不用向它们的方法体中复制日志代码。
不过,并不是每次调用都能正常返回。把调用改成 cal.div(1, 0),会先看到参数日志,随后抛出 ArithmeticException。因为计算失败,没有返回结果,@AfterReturning 就不会执行。后面讲异常通知时,再为这个情况补上相应处理。
2.6 公共功能以后怎样改
日志已经集中到一个地方,以后修改格式时就不需要到四个计算方法中寻找语句了。假设希望所有计算前的日志都带上“开始计算”提示,只替换 LoggerAspect 中的 before 方法,其余方法保留:
java
@Before(CAL_METHODS)
public void before(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
System.out.println("开始计算:" + methodName
+ ",参数:" + Arrays.toString(joinPoint.getArgs()));
}
重新启动程序后,入口仍然调用 cal.add(1, 2),输出变为:
text
开始计算:add,参数:[1, 2]
add 的结果是:3
3
计算器接口、四个计算方法和调用代码都不必改。代理依然把原参数交给计算器,计算方法执行一次,返回值仍然交给调用者;除零异常也仍会向外传播。
这次修改说明了 AOP 的一个实际用途:日志规则放在切面里,计算规则仍在计算器里。给查询、修改、删除统一调整日志时,也可以采用同样的分工。
这里说计算行为保留,是指参数、计算次数、结果和异常传播没有被改写。打印日志本身仍会增加执行时间;通知如果抛出异常,也可能影响调用。因此真实项目还需要选择合适的日志组件,设计附加日志失败时怎样处理。
如果以后在切面中拒绝调用、修改参数、改变返回值或重试,那就是明确改变了调用行为,不能再笼统地描述为“完全不影响业务”。
2.7 各种通知分别在什么时候执行
前面的成功调用用到了两个通知:
@Before:目标方法执行前。@AfterReturning:目标方法正常返回后。
但计算失败时没有结果日志,我们仍然希望知道失败原因;无论成功还是失败,还可能希望记录一次“调用结束”。这两种需求分别可以用 @AfterThrowing 和 @After 表达。
继续使用原来的 LoggerAspect,保留它已有的通知。先增加这两个注解的导入:
java
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.AfterThrowing;
然后增加异常通知方法:
java
@AfterThrowing(value = CAL_METHODS, throwing = "ex")
public void afterThrowing(RuntimeException ex) {
// 观察目标方法向外抛出的运行时异常,不把它吞掉。
System.out.println("计算失败:" + ex.getClass().getSimpleName());
}
要观察它,在入口中把计算调用替换为:
java
try {
cal.div(1, 0);
} catch (ArithmeticException ex) {
// 异常通知只是记录,异常仍然由调用者接收。
System.out.println("调用者收到:" + ex.getClass().getSimpleName());
}
这时既会看到 计算失败:ArithmeticException,也会看到 调用者收到:ArithmeticException。前者来自通知,后者来自入口,说明异常没有被通知吞掉。因为没有正常结果,返回通知仍然不执行。
异常通知的参数类型也参与匹配。这里声明为 RuntimeException,所以能够接收除零产生的运行时异常;并不是任何类型的异常都无条件传给这个参数。
如果无论成功还是失败都需要记录“调用结束”,再增加:
java
@After(CAL_METHODS)
public void after() {
// 类似 finally:正常返回和抛出异常时都会执行。
System.out.println("计算调用结束");
}
再次执行成功和失败两种调用,都会看到 计算调用结束。这与 @AfterReturning 的区别是:@After 类似 finally,不要求目标方法一定成功;它本身也不会把异常转换成正常结果。
通知多起来后,可以按需求选择,而不是每次把所有注解都写上:只在前面准备数据,用 @Before;只观察成功结果,用 @AfterReturning;只记录失败,用 @AfterThrowing;无论成败都要执行收尾,用 @After。
多个通知放在一起时,不要把源码中的书写顺序当成最终执行顺序。如果不同切面的先后确实关系到业务,再使用 @Order 等机制明确安排;当前例子先关注每种通知在成功或失败时是否执行。
2.8 环绕通知:围住一次调用,记录耗时
刚才的通知分别处理调用前、返回后和异常。如果希望把一次调用从头到尾放在同一个方法中观察,例如“开始计时 → 执行计算 → 记录耗时”,就更适合用环绕通知 @Around。
环绕通知能够决定何时继续调用目标,所以也承担更多责任:不能漏掉目标调用,也不能随意多调用一次。下面先只做计时,保持计算结果和异常继续交给调用者。
为了单独观察耗时,先取消 LoggerAspect 上的 @Component,再使用下面的 TimerAspect。其他计算器和配置类保持不变:
java
package com.yigalaxy.spring001.aop;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class TimerAspect {
@Around("execution(public * com.yigalaxy.spring001.aop.CalImpl.*(..))")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
try {
// 调用目标一次,并将它的返回值原样交给调用者。
return joinPoint.proceed();
} finally {
// 即使目标抛异常,也记录本次耗时。
long elapsed = System.nanoTime() - start;
System.out.println("耗时:" + elapsed + " ns");
}
}
}
ProceedingJoinPoint 也是框架提供的调用信息对象。它除了能够取得方法和参数,还提供 proceed(),表示继续执行本次调用后面的通知和目标方法。
再次运行 cal.add(1, 2),输出形式如下:
text
耗时:某次运行得到的数字 ns
3
先记录开始时间,再执行 proceed();计算成功后取得结果,但在返回给入口之前,还要经过 finally 打印耗时,最后入口才打印 3。耗时数字每次不同,System.nanoTime() 的差值用于测量这段时间,不是当前日期时间。
如果目标抛出异常,finally 同样执行,然后异常继续向外传播。这就是为什么计时放在 finally 里:失败的调用也需要记录时间。
proceed() 是这里的关键:不调用,后面的目标方法就不会执行;调用两次,就可能让目标执行两次。本例恰好调用一次,使用 return 把结果原样交给调用者,也没有用 catch 吞掉目标异常。
真实业务中的接口耗时统计可以采用相同思路,再把结果交给日志或监控组件。涉及下单、扣款等写操作时,更不能为了“再试一次”随意重复调用目标方法。
2.9 Spring AOP 为什么必须经过代理
前面已经实际观察到了日志和计时。回头看它们怎样生效,就会发现调用者用到的不是裸露的计算器对象,而是容器准备的代理。
代理可以理解为调用目标对象之前的一层入口。调用者先进入代理,代理根据切点安排通知,再调用实际计算器。我们的切面代码不用出现在 add 方法体中,就是因为公共处理发生在这段调用过程中。
它主要有两种代理方式:
| 代理方式 | 基本做法 | 使用时要知道的事 |
|---|---|---|
| JDK 动态代理 | 代理实现目标接口 | 使用接口类型接收,调用接口方法 |
| CGLIB 代理 | 生成目标类的子类 | 不能用子类覆盖 final 方法,final 类不能这样代理 |
在本节普通 Spring 配置中,CalImpl 实现了 Cal,默认可采用 JDK 动态代理;没有接口时,Spring 可以使用类代理。@EnableAspectJAutoProxy(proxyTargetClass = true) 可以要求使用类代理。
Spring Boot 的 AOP 自动配置默认倾向于类代理,因而不能简单记成“只要有接口就一定是 JDK 代理”。具体结果还要看配置。
有两个常见的绕过代理的情况。
第一种:自己 new 对象。
java
Cal cal = new CalImpl();
cal.add(1, 2); // 没有经过 Spring 的代理,不会执行这里的切面。
第二种:同一个对象内部直接调用自己的方法。
例如,一个服务的 save() 方法内部写了 this.check(),这次调用直接发生在同一个目标对象内部,没有重新经过外层代理。即使给 check() 配了独立通知,也不会仅仅因为这次内部调用而再次触发它。
需要让另一段业务独立经过切面时,通常把它放到另一个 Bean,通过注入的对象调用。遇到日志或事务不生效,可以先检查三个具体问题:对象有没有交给容器?切点有没有匹配这个方法?调用有没有经过代理?先把这条调用路径找清楚,再检查通知本身的逻辑。
3. 在 Spring Boot 中使用时,哪些地方会简化
到这里,普通 Spring 项目已经能运行计算器和切面了。以后换成 Spring Boot,前面学的容器、组件扫描和代理仍然适用,只是一些依赖和启用配置不必手动逐项安排。
在已经使用 Spring Boot 依赖管理的项目中,AOP 通常直接引入:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Spring Boot 3.x 的该 Starter 会带入所需依赖,默认的 AOP 自动配置通常也会启用代理支持,所以常规项目不必再手动加 @EnableAspectJAutoProxy。
启动类可以这样放在 com.yigalaxy.spring001 包下,让默认扫描覆盖其子包中的计算器、配置和切面:
java
package com.yigalaxy.spring001;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Spring001Application {
public static void main(String[] args) {
SpringApplication.run(Spring001Application.class, args);
}
}
@SpringBootApplication 包含组件扫描和自动配置相关能力,SpringApplication.run(...) 会启动应用并建立容器。之后由其他组件注入 Cal,仍然是在使用容器准备好的对象。
已有项目如果手动标记了 @EnableAspectJAutoProxy,会看到它表达的启用意图;但通常不需要在默认 Starter 配置之上重复添加它。对于关闭了自动配置或调整代理设置的项目,则需要结合实际配置判断。
Spring Boot 简化的是接入步骤,没有改变前面的基本条件:依赖要齐全,扫描要找到组件,业务对象和切面要交给容器,调用要经过代理。如果把切面放到启动类扫描范围之外,或者仍在业务代码中自己创建计算器,也不会因为用了 Spring Boot 就自动生效。

评论