Note 15. AOP 切面编程实战:优雅地分离横切关注点
Note 15. AOP 切面编程实战:优雅地分离横切关注点
Prorise第十五章. AOP 切面编程:优雅地分离横切关注点
摘要: 你的业务代码是否正被日志记录、权限校验、事务管理等非核心逻辑所“污染”?本章,我们将深入学习 Spring 框架的两大基石之一——AOP(面向切面编程)。我们将从 AOP 的核心思想“横切关注点”出发,系统掌握切面(Aspect)、切点(Pointcut)、通知(Advice)等关键概念。更重要的是,我们将不再纸上谈兵,而是从零搭建一个集成 H2 数据库与 MyBatis-Plus 的真实演练环境,亲手实现从全局日志监控到企业级 RBAC(基于角色的访问控制)权限校验切面,真正学会如何使用 AOP 这把“手术刀”,编写出高内聚、低耦合的优雅代码。
本章学习路径
- AOP 思想溯源:从典型的“脏代码”入手,识别“横切关注点”,深刻理解 AOP 为何是整洁架构的基石。
- 核心术语与原理深潜:我们将彻底拆解切面、连接点、通知等五大核心概念,并图文并茂地揭示 Spring AOP 背后动态代理(JDK vs CGLIB)的神秘面纱。
- 切点表达式精解:我们将系统学习
execution指示符的完整语法,并扩展掌握@annotation等多种切点定义方式,实现从“地毯式轰炸”到“精确制导”的全面覆盖。 - 实战环境搭建:我们将从零开始,手把手带你构建一个集成 H2 内存数据库和 MyBatis-Plus 的纯净 Web 脚手架,为后续所有实战演练打下坚实基础。
- 实战一:全局日志与性能监控:我们将利用
execution和@Around通知,构建一个零侵入的、能够覆盖所有 Controller 的全局日志与性能监控系统。 - 实战二:基于注解的精准操作日志:我们将通过自定义
@Log注解,实现对“创建订单”等特定关键业务操作的精准、可描述的日志记录。 - 企业级实战:RBAC 权限控制切面:在深入讲解 RBAC 模型后,我们将挑战一个更复杂的场景,设计
@RequiresRole注解,并通过 AOP 动态查询数据库,实现对 API 的无感、自动的权限控制。 - 避坑与总结:我们将盘点 AOP 最常见的失效场景(如内部调用),并提供标准化的生产级代码模版,助你学以致用。
15.1. AOP 思想的诞生:分离横切关注点
让我们审视一个非常典型的 Service 层方法,这段代码在很多初创项目甚至一些维护中的老系统中都随处可见:
1 |
|
请仔细观察这段代码。createOrder 方法的职责本应是“创建订单”,但现在它承担了太多不属于它的工作:性能监控、权限校验、日志记录。真正的 核心业务逻辑 只有“校验库存”和“创建订单记录”这两步。2
这些与核心业务无关,但又在系统中普遍存在的功能(如日志、安全、事务、监控),它们像一张大网,横向 地交织、切割在每一个 纵向 的业务流程中。在软件工程领域,我们给它们起了一个非常形象的名字:横切关注点。
直接将横切关注点写在业务代码里,会带来三大灾难:
- 代码臃肿,职责不纯:
createOrder方法不再纯粹,违反了“单一职责原则”。 - 维护噩梦,高度耦合:如果想修改日志格式,或者变更权限判断逻辑,你需要修改系统中成百上千个类似的方法。这不仅费时费力,而且极易出错。
- 复用性差,处处拷贝:每一个新方法,你都得把这些非核心逻辑再拷贝一遍,导致了大量的代码重复。
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)
通知是切面的灵魂,它决定了我们的通用逻辑在目标方法的哪个时刻执行。
@Before(前置通知):在目标方法执行 之前 运行。常用于权限校验、日志记录请求参数等。如果在这里抛出异常,目标方法将不会被执行。@AfterReturning(后置通知):在目标方法 成功返回 之后运行。可以获取到方法的返回值,但无法修改它。常用于记录成功操作的日志、处理返回结果等。@AfterThrowing(异常通知):在目标方法 抛出异常 之后运行。可以获取到抛出的异常信息,常用于统一的异常日志记录、发送报警邮件等。@After(最终通知):无论目标方法是成功返回还是抛出异常,它 总会 执行。类似于try-catch-finally中的finally块,常用于释放资源。@Around(环绕通知):这是最强大的通知类型。它像一个“包裹”,将目标方法完全包裹起来。你可以在方法执行前后自定义任何逻辑,甚至可以决定是否执行目标方法、修改返回值、或者处理异常。性能监控、事务管理、缓存等功能都依赖于它。
15.2.3. Spring AOP 的幕后:动态代理
Spring AOP 之所以能做到对业务代码“零侵入”,其核心技术就是 动态代理 (Dynamic Proxy)。
当 Spring 容器启动时,它会执行一个精密的流程:
- 扫描切面:寻找所有被
@Aspect注解标记的 Bean。 - 解析切点:分析每个切面中的切点表达式。
- 匹配 Bean:检查容器中的其他所有 Bean(例如我们写的
OrderServiceImpl),看它们的方法是否符合某个切点的匹配规则。 - 创建代理:如果
OrderServiceImpl的createOrder方法被切点匹配到了,Spring 不会 将原始的OrderServiceImpl实例注入给 Controller。相反,它会基于OrderServiceImpl在内存中动态地创建一个 代理对象 (Proxy)。
这个过程对我们是透明的,但理解其代理策略至关重要:
- JDK 动态代理 (默认):如果目标类(如
OrderServiceImpl)实现了接口(如OrderService),Spring 默认使用 JDK 自带的动态代理。它会创建一个同样实现了OrderService接口的代理类。这种方式要求必须面向接口编程。 - CGLIB 代理:如果目标类没有实现任何接口,Spring 会切换到 CGLIB 库。CGLIB 通过动态地创建一个目标类的 子类 来作为代理。这意味着,如果目标方法被
final修饰,CGLIB 将无法代理。
当外部代码调用 orderService.createOrder() 时,其执行流程如下图所示:
这就是 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(注解类的完整包路径.注解名)
使用方式:
- 先创建一个自定义注解,例如
@OperationLog。 - 在切面中定义切点:
@Pointcut("@annotation(com.example.annotation.OperationLog)")。 - 在需要增强的业务方法上,像使用
@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 | <dependencies> |
15.4.3. 第三步:配置数据库与日志
文件路径:src/main/resources/application.yml
我们需要配置 H2 数据库的连接信息,并开启 H2 的 Web 控制台,以便我们在浏览器中直观地查看数据表和数据。同时,配置 MP 打印执行的 SQL,方便调试。
1 | spring: |
15.4.4. 第四步:初始化数据库脚本
Spring Boot 有一个强大的约定:在启动时会自动执行 src/main/resources/ 目录下的 schema.sql 和 data.sql 文件。我们将利用这个特性来自动创建表和插入初始数据。
文件路径:src/main/resources/schema.sql (用于定义表结构)
1 | -- 如果存在 sys_user 表,则删除 |
文件路径:src/main/resources/data.sql (用于插入初始数据)
1 | -- 插入一个管理员和一个普通用户 |
15.4.5. 第五步:编写实体与 Mapper
最后,我们需要创建与数据库表对应的 Java 实体类和 Mapper 接口。
文件路径:src/main/java/com/example/demo/entity/User.java
1 | package com.example.demo.entity; |
文件路径:src/main/java/com/example/demo/mapper/UserMapper.java
1 | package com.example.demo.mapper; |
文件路径:src/main/java/com/example/demo/DemoApplication.java
1 | package com.example.demo; |
至此,我们的实战脚手架已经搭建完毕。启动应用,访问 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 | package com.example.demo.aspect; |
15.5.2. 创建测试 Controller
为了验证我们的切面是否生效,我们需要创建一个简单的 Controller。
文件路径: src/main/java/com/example/demo/controller/TestController.java
1 | package com.example.demo.controller; |
15.5.3. 验证
启动应用,然后使用浏览器或 curl 访问 http://localhost:8080/hello?name=AOP。
观察控制台,你会看到类似下面格式的日志输出:
1 | ------------------- Request Log Start ------------------- |
我们成功了!在没有修改 TestController 任何代码的情况下,为其增加了详细的日志和性能监控功能。这就是 execution 表达式进行“地毯式”全局拦截的威力。
15.6. [实战二] 基于注解的精准操作日志
全局日志虽然方便,但日志量巨大,且不够聚焦。在实际项目中,我们往往更关心 核心业务操作 的日志(谁,在什么时间,做了什么,结果如何),例如“管理员删除了某个用户”、“用户成功创建了一笔订单”。
解决方案:创建一个 @Log 注解,只在需要记录的关键方法上标注它,并通过切面捕获注解信息,实现精准记录。
15.6.1. 定义 @Log 注解
文件路径: src/main/java/com/example/demo/annotation/Log.java
1 | package com.example.demo.annotation; |
15.6.2. 编写 OperationLogAspect 切面
这次,我们的切点将使用 @annotation 指示符,精确匹配被 @Log 注解标记的方法。
文件路径: src/main/java/com/example/demo/aspect/OperationLogAspect.java
1 | package com.example.demo.aspect; |
15.6.3. 在业务方法上使用
我们在 TestController 中增加一个模拟的敏感操作方法。
文件路径: src/main/java/com/example/demo/controller/TestController.java (新增方法)
1 | // ... 已有代码 |
15.6.4. 验证
启动应用,分别测试成功和失败的场景。
成功场景:访问 http://localhost:8080/deleteUser?userId=123
控制台输出:
1 | 正在执行删除用户 123 的业务逻辑... |
失败场景:访问 http://localhost:8080/deleteUser?userId=-1
控制台输出:
1 | ==== ==== ==== = Operation Log ==== ==== ==== = |
我们再次成功了!现在,只有被 @Log 标记的方法才会被 OperationLogAspect 拦截,实现了精准、按需、且包含业务描述的日志记录。
15.7. 企业级实战:AOP 实现数据权限控制
在前面的实战中,我们已经牛刀小试,体会到了 AOP 在日志记录等场景中的便利。现在,我们将挑战一个真实且极具价值的企业级需求:数据权限控制。这是衡量一个开发者是否具备架构思维和工程化能力的重要试金石。
想象一下,我们正在开发一个后台管理系统,其中有大量的接口。根据需求,某些敏感操作,比如“删除用户”、“重置密码”、“发布公告”,只有具备“管理员”身份的用户才能执行。
最直观的实现方式是什么?在每个 Controller 方法内部,手动添加权限校验逻辑。
1 |
|
这种写法的弊端显而易见:
- 高度重复:每个需要权限校验的方法,都必须重复编写获取用户、判断角色的代码。几十上百个接口下来,代码冗余到无法忍受。
- 业务耦合:权限校验逻辑,属于系统的“安全框架”,本应与“用户删除”这类核心业务逻辑分离。现在它们紧紧地耦合在同一个方法里,使得业务代码不纯粹。
- 维护灾难:如果未来权限规则发生变化,比如新增一个“超级管理员”角色也能删除用户,或者校验逻辑需要增加IP白名单判断。你需要逐一修改所有 Controller 方法中的
if判断,这无疑是一场噩梦,极易遗漏。
这些痛点共同指向了一个软件设计的核心原则:关注点分离。权限校验,就是一个典型的 横切关注点,它像幽灵一样散布在各个业务模块中。而 AOP,正是根治这种问题的最强“银弹”。
我们的目标是:将重复的权限校验逻辑,从业务代码中彻底剥离,封装到一个独立的“卫兵”模块中。业务代码只需一个简单的标记,就能自动获得这个“卫兵”的保护。











