Note 15. AOP 切面编程实战:优雅地分离横切关注点


第十五章. AOP 切面编程:优雅地分离横切关注点

摘要: 你的业务代码是否正被日志记录、权限校验、事务管理等非核心逻辑所“污染”?本章,我们将深入学习 Spring 框架的两大基石之一——AOP(面向切面编程)。我们将从 AOP 的核心思想“横切关注点”出发,系统掌握切面(Aspect)、切点(Pointcut)、通知(Advice)等关键概念。更重要的是,我们将不再纸上谈兵,而是从零搭建一个集成 H2 数据库与 MyBatis-Plus 的真实演练环境,亲手实现从全局日志监控到企业级 RBAC(基于角色的访问控制)权限校验切面,真正学会如何使用 AOP 这把“手术刀”,编写出高内聚、低耦合的优雅代码。

本章学习路径

  1. AOP 思想溯源:从典型的“脏代码”入手,识别“横切关注点”,深刻理解 AOP 为何是整洁架构的基石。
  2. 核心术语与原理深潜:我们将彻底拆解切面、连接点、通知等五大核心概念,并图文并茂地揭示 Spring AOP 背后动态代理(JDK vs CGLIB)的神秘面纱。
  3. 切点表达式精解:我们将系统学习 execution 指示符的完整语法,并扩展掌握 @annotation 等多种切点定义方式,实现从“地毯式轰炸”到“精确制导”的全面覆盖。
  4. 实战环境搭建:我们将从零开始,手把手带你构建一个集成 H2 内存数据库和 MyBatis-Plus 的纯净 Web 脚手架,为后续所有实战演练打下坚实基础。
  5. 实战一:全局日志与性能监控:我们将利用 execution@Around 通知,构建一个零侵入的、能够覆盖所有 Controller 的全局日志与性能监控系统。
  6. 实战二:基于注解的精准操作日志:我们将通过自定义 @Log 注解,实现对“创建订单”等特定关键业务操作的精准、可描述的日志记录。
  7. 企业级实战:RBAC 权限控制切面:在深入讲解 RBAC 模型后,我们将挑战一个更复杂的场景,设计 @RequiresRole 注解,并通过 AOP 动态查询数据库,实现对 API 的无感、自动的权限控制。
  8. 避坑与总结:我们将盘点 AOP 最常见的失效场景(如内部调用),并提供标准化的生产级代码模版,助你学以致用。

15.1. AOP 思想的诞生:分离横切关注点

让我们审视一个非常典型的 Service 层方法,这段代码在很多初创项目甚至一些维护中的老系统中都随处可见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
public class OrderServiceImpl implements OrderService {
public void createOrder(OrderCreateDTO dto) {
// --- 非核心逻辑:性能监控 ---
long start = System.currentTimeMillis();

// --- 非核心逻辑:简陋的权限校验 ---
if (!"admin".equals(UserContext.getCurrentUser().getRole())) {
throw new RuntimeException("无权操作");
}

// --- 非核心逻辑:日志记录 ---
log.info("开始创建订单,操作人: {}", UserContext.getCurrentUser().getName());

// --- 核心业务逻辑(真正的价值所在) ---
log.info("1. 校验库存...");
productService.decreaseStock(dto.getProductId(), dto.getQuantity());
log.info("2. 创建订单记录...");
Order order = convertToPO(dto);
orderMapper.insert(order);

// --- 非核心逻辑:收尾工作 ---
log.info("订单创建成功,ID: {}", order.getId());
long duration = System.currentTimeMillis() - start;
log.info("耗时: {}ms", duration);
}
}

请仔细观察这段代码。createOrder 方法的职责本应是“创建订单”,但现在它承担了太多不属于它的工作:性能监控、权限校验、日志记录。真正的 核心业务逻辑 只有“校验库存”和“创建订单记录”这两步。2

这些与核心业务无关,但又在系统中普遍存在的功能(如日志、安全、事务、监控),它们像一张大网,横向 地交织、切割在每一个 纵向 的业务流程中。在软件工程领域,我们给它们起了一个非常形象的名字:横切关注点

直接将横切关注点写在业务代码里,会带来三大灾难:

  1. 代码臃肿,职责不纯createOrder 方法不再纯粹,违反了“单一职责原则”。
  2. 维护噩梦,高度耦合:如果想修改日志格式,或者变更权限判断逻辑,你需要修改系统中成百上千个类似的方法。这不仅费时费力,而且极易出错。
  3. 复用性差,处处拷贝:每一个新方法,你都得把这些非核心逻辑再拷贝一遍,导致了大量的代码重复。

AOP(Aspect-Oriented Programming,面向切面编程)的诞生,正是为了解决这个痛点。它的核心思想,就是提供一种技术,让我们能将这些“横切关注点”从业务逻辑中 彻底抽离 出来,集中到一个独立的模块(即 切面 Aspect)中进行统一管理和配置。

AOP 如同一把精巧的手术刀,让我们能将附着在业务代码上的“赘生物”干净利落地切除,让业务逻辑回归纯粹,让通用功能易于维护和扩展。


15.2. 核心概念与原理深潜

在上一节中,我们理解了 AOP 的“初心”——解耦。为了驾驭这把“手术刀”,我们必须掌握它的操作指南(核心术语)以及它是如何工作的(工作原理)。

15.2.1. AOP 五大核心术语精解

术语英文通俗解释深入理解
切面 (Aspect)Aspect一个“插件”或“工具包”。它是一个普通的 Java 类,被 @Aspect 标记,封装了我们要织入的通用逻辑。切面 = 切点 (在哪里切) + 通知 (切了做什么)。它是一个完整的、可复用的横切关注点模块。
连接点 (Join Point)JoinPoint程序执行过程中的一个“潜在时刻”这是一个理论概念,代表任何可能被拦截的点,如方法调用、字段访问、异常抛出。但在 Spring AOP 中,连接点特指且仅指方法的执行
切点 (Pointcut)Pointcut“瞄准器”或“查询规则”。它是一个表达式,用于从所有连接点中筛选出我们感兴趣的一批。如果说连接点是程序中所有的方法,那切点就是一条 SELECT 语句,用来精确地找出我们想要增强的那些方法。
通知 (Advice)Advice“切入后做什么”的具体动作。这是我们编写的通用逻辑代码,根据执行时机的不同,分为多种类型。这是切面的核心,是真正干活的代码。Spring 提供了五种通知类型,我们稍后详解。
织入 (Weaving)Weaving切面 应用到 目标对象 上,生成一个 代理对象 的过程。这是 AOP 生效的“魔法”时刻。Spring AOP 在运行时进行织入,这个过程对开发者是透明的。

15.2.2. 五种通知类型(Advice)

通知是切面的灵魂,它决定了我们的通用逻辑在目标方法的哪个时刻执行。

  1. @Before (前置通知):在目标方法执行 之前 运行。常用于权限校验、日志记录请求参数等。如果在这里抛出异常,目标方法将不会被执行。
  2. @AfterReturning (后置通知):在目标方法 成功返回 之后运行。可以获取到方法的返回值,但无法修改它。常用于记录成功操作的日志、处理返回结果等。
  3. @AfterThrowing (异常通知):在目标方法 抛出异常 之后运行。可以获取到抛出的异常信息,常用于统一的异常日志记录、发送报警邮件等。
  4. @After (最终通知):无论目标方法是成功返回还是抛出异常,它 总会 执行。类似于 try-catch-finally 中的 finally 块,常用于释放资源。
  5. @Around (环绕通知):这是最强大的通知类型。它像一个“包裹”,将目标方法完全包裹起来。你可以在方法执行前后自定义任何逻辑,甚至可以决定是否执行目标方法、修改返回值、或者处理异常。性能监控、事务管理、缓存等功能都依赖于它。

15.2.3. Spring AOP 的幕后:动态代理

Spring AOP 之所以能做到对业务代码“零侵入”,其核心技术就是 动态代理 (Dynamic Proxy)

当 Spring 容器启动时,它会执行一个精密的流程:

  1. 扫描切面:寻找所有被 @Aspect 注解标记的 Bean。
  2. 解析切点:分析每个切面中的切点表达式。
  3. 匹配 Bean:检查容器中的其他所有 Bean(例如我们写的 OrderServiceImpl),看它们的方法是否符合某个切点的匹配规则。
  4. 创建代理:如果 OrderServiceImplcreateOrder 方法被切点匹配到了,Spring 不会 将原始的 OrderServiceImpl 实例注入给 Controller。相反,它会基于 OrderServiceImpl 在内存中动态地创建一个 代理对象 (Proxy)

这个过程对我们是透明的,但理解其代理策略至关重要:

  • JDK 动态代理 (默认):如果目标类(如 OrderServiceImpl)实现了接口(如 OrderService),Spring 默认使用 JDK 自带的动态代理。它会创建一个同样实现了 OrderService 接口的代理类。这种方式要求必须面向接口编程。
  • CGLIB 代理:如果目标类没有实现任何接口,Spring 会切换到 CGLIB 库。CGLIB 通过动态地创建一个目标类的 子类 来作为代理。这意味着,如果目标方法被 final 修饰,CGLIB 将无法代理。

当外部代码调用 orderService.createOrder() 时,其执行流程如下图所示:

mermaid-diagram-2025-12-16-111224

这就是 AOP 的魔力所在:你的业务代码 OrderServiceImpl 毫不知情,但它已经被一个包含通用逻辑的代理对象“增强”了。


15.3. 切点表达式语法解析

在上一节中,我们知道了切点(Pointcut)是用来定义“在哪些方法上应用切面”的规则。本节我们将深入学习两种最常用的切点定义方式:execution(地毯式覆盖)和 @annotation(精确制定)。

15.3.1. execution:规则匹配的王者

这是最强大也最常用的指示符,适用于对某一类方法进行统一处理。它的语法看似复杂,但一旦掌握,便能随心所欲地定位任何方法。

完整语法结构
execution( [修饰符] 返回值类型 [包名.类名.]方法名(参数类型) [异常] )

  • 方括号 [] 内的部分是可选的。

核心通配符

  • *:匹配任意一个元素。可以匹配任意返回值类型、类名、方法名中的一部分或一个参数。
  • ..:匹配任意数量的元素。可以匹配任意数量的子包,或任意数量、任意类型的参数。

实战案例拆解

  • 匹配任意公共方法

    1
    execution(public * *(..))
    • public: 精确匹配 public 修饰的方法。
    • *: 匹配任意返回值类型。
    • *: 匹配任意类。
  • *(..): 匹配任意方法名和任意参数。

  • 匹配特定包下的所有类和方法(最常用)

    1
    execution(* com.example.service..*.*(..))
    • *: 匹配任意返回值。
    • com.example.service..: 匹配 com.example.service 包及其所有子包。
    • *: 匹配包下的所有类。
    • .*: 匹配类中的所有方法。
    • (..): 匹配任意参数。
  • 匹配以 Service 结尾的类中的方法

    1
    execution(* com.example..*Service.*(..))
    • com.example..: 匹配 com.example 及其子包。
    • *Service: 匹配所有以 Service 结尾的类名(如 UserService, OrderService)。

15.3.2. @annotation:按需标记的利器

execution 虽然强大,但有时过于粗暴。如果我们只想对系统中的某几个 关键业务方法(如“转账”、“删除用户”)进行特殊处理,为它们一一编写 execution 表达式会非常繁琐且不易维护。此时,使用自定义注解进行标记是更优雅的选择。

语法结构
@annotation(注解类的完整包路径.注解名)

使用方式

  1. 先创建一个自定义注解,例如 @OperationLog
  2. 在切面中定义切点:@Pointcut("@annotation(com.example.annotation.OperationLog)")
  3. 在需要增强的业务方法上,像使用 @GetMapping 一样,直接标注 @OperationLog 即可。

这种方式的侵入性稍强(需要在业务方法上添加注解),但换来的是极高的灵活性和可读性,一眼就能看出哪些方法被特殊增强了。


15.4. [实战] 搭建 Springboot 脚手架

理论讲得再多,不如亲手敲一遍。在后续的实战中,我们将实现日志、监控、权限校验等切面,其中权限校验需要与数据库交互。为了不让大家对着伪代码空想,我们将从零开始,搭建一个包含 H2 数据库MyBatis-Plus 的纯净环境。

为何选择 H2 和 MyBatis-Plus?

  • H2 数据库:一个纯 Java 编写的内存数据库。它无需安装、配置简单、启动极快,非常适合用于学习、演示和单元测试。项目关闭后数据即销毁。
  • MyBatis-Plus (MP):MyBatis 的增强工具。它提供了大量通用的 CRUD 方法,让我们无需编写任何 SQL 语句就能完成大部分单表操作,可以极大地简化我们的数据层代码。

15.4.1. 第一步:创建 Spring Boot 项目

访问 Spring Initializr 或,创建一个新的 Spring Boot 项目,并添加以下依赖:

  • Spring Web
  • Spring Boot Starter AOP
  • Lombok
  • H2 Database
  • MyBatis-Plus

15.4.2. 第二步:引入核心依赖

如果您是手动配置,请确保 pom.xml 文件中包含以下核心依赖:

文件路径pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<dependencies>
<!-- 1. Web 模块 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 2. AOP 切面模块 (本章主角) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!-- 3. MyBatis-Plus (简化数据库操作, 2025年稳定版本) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>

<!-- 4. H2 数据库 (内存模式,无需安装) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

<!-- 5. Lombok (简化代码) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>

15.4.3. 第三步:配置数据库与日志

文件路径src/main/resources/application.yml

我们需要配置 H2 数据库的连接信息,并开启 H2 的 Web 控制台,以便我们在浏览器中直观地查看数据表和数据。同时,配置 MP 打印执行的 SQL,方便调试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
spring:
# 数据源配置
datasource:
# H2 内存模式,数据库名为 testdb
# DB_CLOSE_DELAY=-1 表示即使所有连接关闭,数据库也不会被关闭,直到 JVM 关闭
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
# H2 Web 控制台配置
h2:
console:
enabled: true # 开启 H2 控制台
path: /h2-console # 访问路径为 http://localhost:8080/h2-console

# MyBatis-Plus 配置
mybatis-plus:
configuration:
# 在控制台打印执行的 SQL 语句
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

15.4.4. 第四步:初始化数据库脚本

Spring Boot 有一个强大的约定:在启动时会自动执行 src/main/resources/ 目录下的 schema.sqldata.sql 文件。我们将利用这个特性来自动创建表和插入初始数据。

文件路径src/main/resources/schema.sql (用于定义表结构)

1
2
3
4
5
6
7
8
9
-- 如果存在 sys_user 表,则删除
DROP TABLE IF EXISTS sys_user;

-- 创建用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
role VARCHAR(20) NOT NULL -- 为了简化,这里直接将角色字符串存在用户表中
);

文件路径src/main/resources/data.sql (用于插入初始数据)

1
2
3
-- 插入一个管理员和一个普通用户
INSERT INTO sys_user (id, username, role) VALUES (1, 'admin_user', 'ADMIN');
INSERT INTO sys_user (id, username, role) VALUES (2, 'normal_user', 'USER');

15.4.5. 第五步:编写实体与 Mapper

最后,我们需要创建与数据库表对应的 Java 实体类和 Mapper 接口。

文件路径src/main/java/com/example/demo/entity/User.java

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("sys_user") // 使用 @TableName 注解将实体类与数据库表 sys_user 关联
public class User {
private Long id;
private String username;
private String role;
}

文件路径src/main/java/com/example/demo/mapper/UserMapper.java

1
2
3
4
5
6
7
8
9
10
package com.example.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper // 标记为 MyBatis 的 Mapper 接口,Spring 会为它创建实现类
public interface UserMapper extends BaseMapper<User> {
// 继承了 BaseMapper 后,就自动拥有了强大的 CRUD 能力,无需手写任何 SQL 和 XML
}

文件路径src/main/java/com/example/demo/DemoApplication.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.demo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.example.demo.mapper") // 扫描 Mapper 接口所在的包
public class DemoApplication {

public static void main(String [] args) {
SpringApplication.run(DemoApplication.class, args);
}

}

至此,我们的实战脚手架已经搭建完毕。启动应用,访问 http://localhost:8080/h2-console,输入 JDBC URL jdbc:h2:mem:testdb,用户名 root,即可看到我们已经创建好的 sys_user 表和两条初始数据。


15.5. [实战一] 全局日志与性能监控

在搭建好的环境中,我们来完成第一个任务:为所有 Controller 层的接口自动记录请求的详细信息(URL, IP, 参数等)和方法的执行耗时。

我们的目标:不修改任何一行 Controller 代码,实现即插即用的日志监控。

15.5.1. 编写 WebLogAspect 切面

我们将创建一个切面,使用 execution 表达式来匹配 controller 包下的所有公共方法,并使用 @Around 环绕通知来包裹这些方法的执行过程。

文件路径src/main/java/com/example/demo/aspect/WebLogAspect.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.example.demo.aspect;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.util.Arrays;

@Aspect // 声明这是一个切面类
@Component // 将其作为 Bean 交给 Spring 容器管理
@Slf4j // 使用 Lombok 提供的日志功能
public class WebLogAspect {

/**
* 定义切点。
* 匹配 com.example.demo.controller 包及其子包下的所有类的所有公共方法。
*/
@Pointcut(" execution(public * com.example.demo.controller..*.*(..))")
public void webLogPointcut() {}

/**
* @Around 是最强大的通知类型,它能完全控制目标方法的执行。
* ProceedingJoinPoint 参数是 JoinPoint 的子类,只能在 @Around 通知中使用,
* 它提供了一个 proceed() 方法来执行原始的目标方法。
*/
@Around("webLogPointcut()")
public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
long startTime = System.currentTimeMillis();

// 1. 获取当前 HTTP 请求信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = (attributes != null) ? attributes.getRequest() : null;

Object result;
// 必须用 try-finally 来确保无论方法成功还是异常,日志都能被记录
try {
// 2. 执行目标方法
// pjp.proceed() 的返回值就是目标方法的返回值
result = pjp.proceed();
return result;
} finally {
long duration = System.currentTimeMillis() - startTime;

// 3. 记录完整的请求日志
if (request != null) {
Signature signature = pjp.getSignature();
log.info("------------------- Request Log Start -------------------");
log.info("请求 URL : {}", request.getRequestURL());
log.info("HTTP Method : {}", request.getMethod());
log.info("来源 IP : {}", request.getRemoteAddr());
log.info("执行的类.方法 : {}.{}", signature.getDeclaringTypeName(), signature.getName());
log.info("请求参数 : {}", Arrays.toString(pjp.getArgs()));
log.info("执行耗时 : {} ms", duration);
log.info("------------------- Request Log End ---------------------");
}
}
}
}

15.5.2. 创建测试 Controller

为了验证我们的切面是否生效,我们需要创建一个简单的 Controller。

文件路径: src/main/java/com/example/demo/controller/TestController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.demo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

@GetMapping("/hello")
public String sayHello(@RequestParam("name") String name) throws InterruptedException {
// 模拟业务耗时
Thread.sleep(100);
return "Hello, " + name;
}
}

15.5.3. 验证

启动应用,然后使用浏览器或 curl 访问 http://localhost:8080/hello?name=AOP

观察控制台,你会看到类似下面格式的日志输出:

1
2
3
4
5
6
7
8
------------------- Request Log Start -------------------
请求 URL : http://localhost: 8080/hello
HTTP Method : GET
来源 IP : 0:0:0:0:0:0:0:1
执行的类.方法 : com.example.demo.controller.TestController.sayHello
请求参数 : [AOP]
执行耗时 : 105 ms
------------------- Request Log End ---------------------

我们成功了!在没有修改 TestController 任何代码的情况下,为其增加了详细的日志和性能监控功能。这就是 execution 表达式进行“地毯式”全局拦截的威力。


15.6. [实战二] 基于注解的精准操作日志

全局日志虽然方便,但日志量巨大,且不够聚焦。在实际项目中,我们往往更关心 核心业务操作 的日志(谁,在什么时间,做了什么,结果如何),例如“管理员删除了某个用户”、“用户成功创建了一笔订单”。

解决方案:创建一个 @Log 注解,只在需要记录的关键方法上标注它,并通过切面捕获注解信息,实现精准记录。

15.6.1. 定义 @Log 注解

文件路径: src/main/java/com/example/demo/annotation/Log.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.demo.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD) // 此注解只能用于方法上
@Retention(RetentionPolicy.RUNTIME) // 注解在运行时可见,AOP 才能读取到
@Documented
public @interface Log {
/**
* 操作的业务描述, e.g., "创建用户", "更新订单状态"
*/
String description();
}

15.6.2. 编写 OperationLogAspect 切面

这次,我们的切点将使用 @annotation 指示符,精确匹配被 @Log 注解标记的方法。

文件路径: src/main/java/com/example/demo/aspect/OperationLogAspect.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package com.example.demo.aspect;

import com.example.demo.annotation.Log;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Aspect
@Component
@Slf4j
public class OperationLogAspect {

/**
* 定义切点,匹配所有被 @Log 注解标记的方法。
*/
@Pointcut("@annotation(com.example.demo.annotation.Log)")
public void operationLogPointcut() {}

@Around("operationLogPointcut()")
public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
Object result;
String status = "成功";

try {
result = pjp.proceed();
return result;
} catch (Throwable e) {
status = "失败";
// 异常需要继续向上抛出,以便全局异常处理器能够捕获并处理
throw e;
} finally {
// 通过反射获取方法上的注解信息
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
Log logAnnotation = method.getAnnotation(Log.class);
String description = logAnnotation.description();

// 模拟记录操作日志到数据库或日志文件
log.info("==== ==== ==== = Operation Log ==== ==== ==== =");
log.info("业务操作: {}", description);
// 在真实项目中,操作人应从 SecurityContext 或 Token 中获取
log.info("操作人: {}", "Admin");
log.info("执行状态: {}", status);
log.info("==== ==== ==== ==== ==== ==== ==== ==== ==== ====");
}
}
}

15.6.3. 在业务方法上使用

我们在 TestController 中增加一个模拟的敏感操作方法。

文件路径: src/main/java/com/example/demo/controller/TestController.java (新增方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ... 已有代码

import com.example.demo.annotation.Log;
// ...

@GetMapping("/deleteUser")
@Log(description = "删除用户操作") // 添加我们的自定义注解
public String deleteUser(@RequestParam("userId") Long userId) {
if (userId < 0) {
// 模拟一个失败场景
throw new IllegalArgumentException("用户 ID 不能为负数");
}
log.info("正在执行删除用户 {} 的业务逻辑...", userId);
return "用户 " + userId + " 删除成功";
}

15.6.4. 验证

启动应用,分别测试成功和失败的场景。

成功场景:访问 http://localhost:8080/deleteUser?userId=123
控制台输出:

1
2
3
4
5
6
正在执行删除用户 123 的业务逻辑...
==== ==== ==== = Operation Log ==== ==== ==== =
业务操作: 删除用户操作
操作人: Admin
执行状态: 成功
==== ==== ==== ==== ==== ==== ==== ==== ==== ====

失败场景:访问 http://localhost:8080/deleteUser?userId=-1
控制台输出:

1
2
3
4
5
6
7
==== ==== ==== = Operation Log ==== ==== ==== =
业务操作: 删除用户操作
操作人: Admin
执行状态: 失败
==== ==== ==== ==== ==== ==== ==== ==== ==== ====
java.lang.IllegalArgumentException: 用户 ID 不能为负数
...

我们再次成功了!现在,只有被 @Log 标记的方法才会被 OperationLogAspect 拦截,实现了精准、按需、且包含业务描述的日志记录。


15.7. 企业级实战:AOP 实现数据权限控制

在前面的实战中,我们已经牛刀小试,体会到了 AOP 在日志记录等场景中的便利。现在,我们将挑战一个真实且极具价值的企业级需求:数据权限控制。这是衡量一个开发者是否具备架构思维和工程化能力的重要试金石。

想象一下,我们正在开发一个后台管理系统,其中有大量的接口。根据需求,某些敏感操作,比如“删除用户”、“重置密码”、“发布公告”,只有具备“管理员”身份的用户才能执行。

最直观的实现方式是什么?在每个 Controller 方法内部,手动添加权限校验逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@RestController
@RequestMapping("/user")
public class UserController {

@Autowired
private UserService userService;
@Autowired
private UserRoleService userRoleService; // 假设用于查询用户角色

@DeleteMapping("/delete/{id}")
public String deleteUser(@PathVariable Long id, @RequestHeader("X-User-Id") Long currentUserId) {
// 1. 手动获取当前用户角色
String userRole = userRoleService.getRoleByUserId(currentUserId);

// 2. 硬编码进行权限判断
if (! "ADMIN".equals(userRole)) {
// 3. 手动返回错误信息
return "权限不足!";
}

// --- 真正的业务逻辑 ---
userService.deleteById(id);
return "用户删除成功";
}

// 其他几十个需要 ADMIN 权限的接口...
// @PostMapping("/resetPassword")
// @PostMapping("/publishNotice")
// ... 每个方法里都有一段几乎一模一样的 if-else ...
}

这种写法的弊端显而易见:

  • 高度重复:每个需要权限校验的方法,都必须重复编写获取用户、判断角色的代码。几十上百个接口下来,代码冗余到无法忍受。
  • 业务耦合:权限校验逻辑,属于系统的“安全框架”,本应与“用户删除”这类核心业务逻辑分离。现在它们紧紧地耦合在同一个方法里,使得业务代码不纯粹。
  • 维护灾难:如果未来权限规则发生变化,比如新增一个“超级管理员”角色也能删除用户,或者校验逻辑需要增加IP白名单判断。你需要逐一修改所有 Controller 方法中的 if 判断,这无疑是一场噩梦,极易遗漏。

这些痛点共同指向了一个软件设计的核心原则:关注点分离。权限校验,就是一个典型的 横切关注点,它像幽灵一样散布在各个业务模块中。而 AOP,正是根治这种问题的最强“银弹”。

我们的目标是:将重复的权限校验逻辑,从业务代码中彻底剥离,封装到一个独立的“卫兵”模块中。业务代码只需一个简单的标记,就能自动获得这个“卫兵”的保护。