AOP
部分笔记来自黑马程序员。
一、AOP概述
什么是AOP?
- AOP英文全称:Aspect Oriented Programming(面向切面编程、面向方面编程),其实说白了,面向切面编程就是面向特定方法编程。
那什么又是面向方法编程呢,为什么又需要面向方法编程呢?来我们举个例子做一个说明:
比如,我们这里有一个项目,项目中开发了很多的业务功能。
然而有一些业务功能执行效率比较低,执行耗时较长,我们需要针对于这些业务方法进行优化。 那首先第一步就需要定位出执行耗时比较长的业务方法,再针对于业务方法再来进行优化。
此时我们就需要统计当前这个项目当中每一个业务方法的执行耗时。那么统计每一个业务方法的执行耗时该怎么实现?
可能多数人首先想到的就是在每一个业务方法运行之前,记录这个方法运行的开始时间。在这个方法运行完毕之后,再来记录这个方法运行的结束时间。拿结束时间减去开始时间,不就是这个方法的执行耗时吗?
以上分析的实现方式是可以解决需求问题的。但是对于一个项目来讲,里面会包含很多的业务模块,每个业务模块又包含很多增删改查的方法,如果我们要在每一个模块下的业务方法中,添加记录开始时间、结束时间、计算执行耗时的代码,就会让程序员的工作变得非常繁琐。
而AOP面向方法编程,就可以做到在不改动这些原始方法的基础上,针对特定的方法进行功能的增强。
AOP的作用:在程序运行期间在不修改源代码的基础上对已有方法进行增强(无侵入性: 解耦)
我们要想完成统计各个业务方法执行耗时的需求,我们只需要定义一个模板方法,将记录方法执行耗时这一部分公共的逻辑代码,定义在模板方法当中,在这个方法开始运行之前,来记录这个方法运行的开始时间,在方法结束运行的时候,再来记录方法运行的结束时间,中间就来运行原始的业务方法。
而中间运行的原始业务方法,可能是其中的一个业务方法,比如:我们只想通过 部门管理的 list 方法的执行耗时,那就只有这一个方法是原始业务方法。 而如果,我们是先想统计所有部门管理的业务方法执行耗时,那此时,所有的部门管理的业务方法都是 原始业务方法。 那面向这样的指定的一个或多个方法进行编程,我们就称之为 面向切面编程。
AOP的优势:
- 减少重复代码
- 提高开发效率
- 维护方便
二、AOP快速入门
在了解了什么是AOP后,我们下面通过一个快速入门程序,体验下AOP的开发,并掌握Spring中AOP的开发步骤。
**需求:**统计各个业务层方法执行耗时。
实现步骤:
- 导入依赖:在pom.xml中导入AOP的依赖
- 编写AOP程序:针对于特定方法根据业务需要进行编程
开始实现
1、新建 springboot-aop-demo
2、引入依赖 pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
3、定义切面类 TimeAspect
// 使用@Component注解声明这是一个Spring组件,Spring会自动扫描并管理此类的实例
@Component
// 使用@Aspect注解声明这是一个切面类,用于定义横切关注点(如日志、事务等)
@Aspect
// 使用@Slf4j注解来自动生成日志对象log,方便进行日志记录
@Slf4j
public class TimeAspect {
// 定义一个环绕通知,针对com.aopdemo.service包下的所有类的所有方法
@Around("execution(* com.aopdemo.service.*.*(..))")
public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
// 记录方法执行的开始时间
long begin = System.currentTimeMillis();
// 执行被拦截的方法,并获取其返回结果
// ProceedingJoinPoint接口提供了对连接点进行操作的能力,这里用来调用目标方法
Object result = pjp.proceed();
// 记录方法执行的结束时间
long end = System.currentTimeMillis();
// 计算方法执行的总耗时
// pjp.getSignature()可以获得被拦截方法的签名信息,包括类名和方法名等
log.info(pjp.getSignature() + "执行耗时: {}毫秒", end - begin);
// 返回被拦截方法的原始返回值
return result;
}
}
我们通过AOP入门程序完成了业务方法执行耗时的统计,那其实AOP的功能远不止于此,常见的应用场景如下:
- 记录系统的操作日志
- 权限控制
- 事务管理:我们前面所讲解的Spring事务管理,底层其实也是通过AOP来实现的,只要添加@Transactional注解之后,AOP程序自动会在原始方法运行前先来开启事务,在原始方法运行完毕之后提交或回滚事务
这些都是AOP应用的典型场景。
通过入门程序,我们也应该感受到了AOP面向切面编程的一些优势:
-
代码无侵入:没有修改原始的业务方法,就已经对原始的业务方法进行了功能的增强或者是功能的改变
-
减少了重复代码
-
提高开发效率
-
维护方便
三、AOP核心概念
通过SpringAOP的快速入门,感受了一下AOP面向切面编程的开发方式。下面我们再来学习AOP当中涉及到的一些核心概念。
1. 连接点:JoinPoint,可以被AOP控制的方法(暗含方法执行时的相关信息)
连接点指的是可以被aop控制的方法。例如:入门程序当中所有的业务方法都是可以被aop控制的方法。
在SpringAOP提供的JoinPoint当中,封装了连接点方法在执行时的相关信息。
2. 通知:Advice,指哪些重复的逻辑,也就是共性功能(最终体现为一个方法)
在入门程序中是需要统计各个业务方法的执行耗时的,此时我们就需要在这些业务方法运行开始之前,先记录这个方法运行的开始时间,在每一个业务方法运行结束的时候,再来记录这个方法运行的结束时间。
但是在AOP面向切面编程当中,我们只需要将这部分重复的代码逻辑抽取出来单独定义。抽取出来的这一部分重复的逻辑,也就是共性的功能。
3. 切入点:PointCut,匹配连接点的条件,通知仅会在切入点方法执行时被应用
在通知当中,我们所定义的共性功能到底要应用在哪些方法上?此时就涉及到了切入点pointcut概念。切入点指的是匹配连接点的条件。通知仅会在切入点方法运行时才会被应用。
在aop的开发当中,我们通常会通过一个切入点表达式来描述切入点
假如:切入点表达式改为DeptServiceImpl.list(),此时就代表仅仅只有list这一个方法是切入点。只有list()方法在运行的时候才会应用通知。
4. 切面:Aspect,描述通知与切入点的对应关系(通知+切入点)
当通知和切入点结合在一起,就形成了一个切面。通过切面就能够描述当前aop程序需要针对于哪个原始方法,在什么时候执行什么样的操作。
切面所在的类,我们一般称为切面类(被@Aspect注解标识的类)
5. 目标对象:Target,通知所应用的对象
目标对象指的就是通知所应用的对象,我们就称之为目标对象。
四、 AOP进阶
AOP的基础知识学习完之后,下面我们对AOP当中的各个细节进行详细的学习。主要分为4个部分:
- 通知类型
- 通知顺序
- 切入点表达式
- 连接点
我们先来学习第一部分通知类型。
1、通知类型
在入门程序当中,我们已经使用了一种功能最为强大的通知类型:Around环绕通知。
@Around("execution(* com.itheima.service.*.*(..))")
public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
//记录方法执行开始时间
long begin = System.currentTimeMillis();
//执行原始方法
Object result = pjp.proceed();
//记录方法执行结束时间
long end = System.currentTimeMillis();
//计算方法执行耗时
log.info(pjp.getSignature()+"执行耗时: {}毫秒",end-begin);
return result;
}
只要我们在通知方法上加上了@Around注解,就代表当前通知是一个环绕通知。
Spring中AOP的通知类型:
- @Around:环绕通知,此注解标注的通知方法在目标方法前、后都被执行
- @Before:前置通知,此注解标注的通知方法在目标方法前被执行
- @After :后置通知,此注解标注的通知方法在目标方法后被执行,无论是否有异常都会执行
- @AfterReturning : 返回后通知,此注解标注的通知方法在目标方法后被执行,有异常不会执行
- @AfterThrowing : 异常后通知,此注解标注的通知方法发生异常后执行
下面我们通过代码演示,来加深对于不同通知类型的理解:
@Slf4j
@Component
@Aspect
public class MyAspect1 {
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")
public void before(JoinPoint joinPoint){
log.info("before ...");
}
//环绕通知
@Around("execution(* com.itheima.service.*.*(..))")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
log.info("around before ...");
//调用目标对象的原始方法执行
Object result = proceedingJoinPoint.proceed();
//原始方法如果执行时有异常,环绕通知中的后置代码不会在执行了
log.info("around after ...");
return result;
}
//后置通知
@After("execution(* com.itheima.service.*.*(..))")
public void after(JoinPoint joinPoint){
log.info("after ...");
}
//返回后通知(程序在正常执行的情况下,会执行的后置通知)
@AfterReturning("execution(* com.itheima.service.*.*(..))")
public void afterReturning(JoinPoint joinPoint){
log.info("afterReturning ...");
}
//异常通知(程序在出现异常的情况下,执行的后置通知)
@AfterThrowing("execution(* com.itheima.service.*.*(..))")
public void afterThrowing(JoinPoint joinPoint){
log.info("afterThrowing ...");
}
}
我们发现啊,每一个注解里面都指定了切入点表达式,而且这些切入点表达式都一模一样。此时我们的代码当中就存在了大量的重复性的切入点表达式,假如此时切入点表达式需要变动,就需要将所有的切入点表达式一个一个的来改动,就变得非常繁琐了。
怎么来解决这个切入点表达式重复的问题? 答案就是:抽取
Spring提供了@PointCut注解,该注解的作用是将公共的切入点表达式抽取出来,需要用到时引用该切入点表达式即可。
@Slf4j
@Component
@Aspect
public class MyAspect1 {
//切入点方法(公共的切入点表达式)
@Pointcut("execution(* com.itheima.service.*.*(..))")
private void pt(){
}
//前置通知(引用切入点)
@Before("pt()")
public void before(JoinPoint joinPoint){
log.info("before ...");
}
//环绕通知
@Around("pt()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
log.info("around before ...");
//调用目标对象的原始方法执行
Object result = proceedingJoinPoint.proceed();
//原始方法在执行时:发生异常
//后续代码不在执行
log.info("around after ...");
return result;
}
//后置通知
@After("pt()")
public void after(JoinPoint joinPoint){
log.info("after ...");
}
//返回后通知(程序在正常执行的情况下,会执行的后置通知)
@AfterReturning("pt()")
public void afterReturning(JoinPoint joinPoint){
log.info("afterReturning ...");
}
//异常通知(程序在出现异常的情况下,执行的后置通知)
@AfterThrowing("pt()")
public void afterThrowing(JoinPoint joinPoint){
log.info("afterThrowing ...");
}
}
2、 通知顺序
讲解完了Spring中AOP所支持的5种通知类型之后,接下来我们再来研究通知的执行顺序。
当在项目开发当中,我们定义了多个切面类,而多个切面类中多个切入点都匹配到了同一个目标方法。此时当目标方法在运行的时候,这多个切面类当中的这些通知方法都会运行。
此时我们就有一个疑问,这多个通知方法到底哪个先运行,哪个后运行? 下面我们通过程序来验证(这里呢,我们就定义两种类型的通知进行测试,一种是前置通知@Before,一种是后置通知@After)
在不同切面类中,默认按照切面类的类名字母排序:
- 目标方法前的通知方法:字母排名靠前的先执行
- 目标方法后的通知方法:字母排名靠前的后执行
如果我们想控制通知的执行顺序有两种方式:
- 修改切面类的类名(这种方式非常繁琐、而且不便管理)
- 使用Spring提供的@Order注解
使用@Order注解,控制通知的执行顺序:
@Slf4j
@Component
@Aspect
@Order(3) //切面类的执行顺序(前置通知:数字越小先执行; 后置通知:数字越小越后执行)
public class MyAspect3 {
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")
public void before(){
log.info("MyAspect3 -> before ...");
}
//后置通知
@After("execution(* com.itheima.service.*.*(..))")
public void after(){
log.info("MyAspect3 -> after ...");
}
}
通知的执行顺序大家主要知道两点即可:
- 不同的切面类当中,默认情况下通知的执行顺序是与切面类的类名字母排序是有关系的
- 可以在切面类上面加上@Order注解,来控制不同的切面类通知的执行顺序
3、 切入点表达式
从AOP的入门程序到现在,我们一直都在使用切入点表达式来描述切入点。下面我们就来详细的介绍一下切入点表达式的具体写法。
切入点表达式:
-
描述切入点方法的一种表达式
-
作用:主要用来决定项目中的哪些方法需要加入通知
-
常见形式:
-
execution(……):根据方法的签名来匹配
-
@annotation(……) :根据注解匹配
-
首先我们先学习第一种最为常见的execution切入点表达式。
1、execution
execution主要根据方法的返回值、包名、类名、方法名、方法参数等信息来匹配,语法为:
其中带?
的表示可以省略的部分
-
访问修饰符:可省略(比如: public、protected)
-
包名.类名: 可省略
-
throws 异常:可省略(注意是方法上声明抛出的异常,不是实际抛出的异常)
示例:
@Before("execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))")
可以使用通配符描述切入点
-
*
:单个独立的任意符号,可以通配任意返回值、包名、类名、方法名、任意类型的一个参数,也可以通配包、类、方法名的一部分 -
..
:多个连续的任意符号,可以通配任意层级的包,或任意类型、任意个数的参数
切入点表达式的语法规则:
- 方法的访问修饰符可以省略
- 返回值可以使用
*
号代替(任意返回值类型) - 包名可以使用
*
号代替,代表任意包(一层包使用一个*
) - 使用
..
配置包名,标识此包以及此包下的所有子包 - 类名可以使用
*
号代替,标识任意类 - 方法名可以使用
*
号代替,表示任意方法 - 可以使用
*
配置参数,一个任意类型的参数 - 可以使用
..
配置参数,任意个任意类型的参数
切入点表达式示例
-
省略方法的修饰符号
execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
-
使用
*
代替返回值类型execution(* com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
-
使用
*
代替包名(一层包使用一个*
)execution(* com.itheima.*.*.DeptServiceImpl.delete(java.lang.Integer))
-
使用
..
省略包名execution(* com..DeptServiceImpl.delete(java.lang.Integer))
-
使用
*
代替类名execution(* com..*.delete(java.lang.Integer))
-
使用
*
代替方法名execution(* com..*.*(java.lang.Integer))
-
使用
*
代替参数execution(* com.itheima.service.impl.DeptServiceImpl.delete(*))
-
使用
..
省略参数execution(* com..*.*(..))
注意事项:
- 根据业务需要,可以使用 且(&&)、或(||)、非(!) 来组合比较复杂的切入点表达式。
execution(* com.itheima.service.DeptService.list(..)) || execution(* com.itheima.service.DeptService.delete(..))
切入点表达式的书写建议:
- 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:查询类方法都是 find 开头,更新类方法都是update开头
//业务类
@Service
public class DeptServiceImpl implements DeptService {
public List<Dept> findAllDept() {
//省略代码...
}
public Dept findDeptById(Integer id) {
//省略代码...
}
public void updateDeptById(Integer id) {
//省略代码...
}
public void updateDeptByMoreCondition(Dept dept) {
//省略代码...
}
//其他代码...
}
//匹配DeptServiceImpl类中以find开头的方法
execution(* com.itheima.service.impl.DeptServiceImpl.find*(..))
2、@annotation
已经学习了execution切入点表达式的语法。那么如果我们要匹配多个无规则的方法,比如:list()和 delete()这两个方法。这个时候我们基于execution这种切入点表达式来描述就不是很方便了。而在之前我们是将两个切入点表达式组合在了一起完成的需求,这个是比较繁琐的。
我们可以借助于另一种切入点表达式annotation来描述这一类的切入点,从而来简化切入点表达式的书写。
实现步骤:
-
编写自定义注解
-
在业务类要做为连接点的方法上添加自定义注解
自定义注解:MyLog
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyLog {
}
业务类:DeptServiceImpl
@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
@Autowired
private DeptMapper deptMapper;
@Override
@MyLog //自定义注解(表示:当前方法属于目标方法)
public List<Dept> list() {
List<Dept> deptList = deptMapper.list();
//模拟异常
//int num = 10/0;
return deptList;
}
@Override
public void save(Dept dept) {
dept.setCreateTime(LocalDateTime.now());
dept.setUpdateTime(LocalDateTime.now());
deptMapper.save(dept);
}
//其他方法、、、、、、、
}
切面类:MyAspect6
@Slf4j
@Component
@Aspect
public class MyAspect6 {
//针对list方法、delete方法进行前置通知和后置通知
//前置通知
@Before("@annotation(com.itheima.anno.MyLog)")
public void before(){
log.info("MyAspect6 -> before ...");
}
//后置通知
@After("@annotation(com.itheima.anno.MyLog)")
public void after(){
log.info("MyAspect6 -> after ...");
}
}
到此我们两种常见的切入点表达式我已经介绍完了。
- execution切入点表达式
- 根据我们所指定的方法的描述信息来匹配切入点方法,这种方式也是最为常用的一种方式
- 如果我们要匹配的切入点方法的方法名不规则,或者有一些比较特殊的需求,通过execution切入点表达式描述比较繁琐
- annotation 切入点表达式
- 基于注解的方式来匹配切入点方法。这种方式虽然多一步操作,我们需要自定义一个注解,但是相对来比较灵活。我们需要匹配哪个方法,就在方法上加上对应的注解就可以了
五、AOP案例
案例1
需求:将案例中增、删、改相关接口的操作日志记录到数据库表中
开始:
AOP起步依赖
<!--AOP起步依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
自定义注解@Log
/**
* 自定义Log注解
* 该注解用于标记方法,以便在方法执行前后添加日志记录逻辑。
*/
@Target({ElementType.METHOD}) // 指定注解可以应用于方法上
@Documented // 表示该注解应该被javadoc工具记录
@Retention(RetentionPolicy.RUNTIME) // 指定注解的保留策略,RUNTIME表示注解将在运行时保留,可以通过反射访问
public @interface Log {
}
修改业务实现类,在增删改业务方法上添加@Log注解
@Slf4j
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Override
@Log
public void update(Emp emp) {
emp.setUpdateTime(LocalDateTime.now()); //更新修改时间为当前时间
empMapper.update(emp);
}
//省略其他代码...
}
定义切面类,完成记录操作日志的逻辑
// 使用Lombok的@Slf4j注解自动生成日志对象,方便记录日志
@Slf4j
// 将这个类标记为Spring的一个组件,这样Spring就能管理这个类的生命周期
@Component
// 标记这个类为一个切面,意味着它可以定义横切关注点,比如日志记录
@Aspect
public class LogAspect {
//用于获取请求信息
@Autowired
private HttpServletRequest request;
// ,用于与数据库交互
@Autowired
private OperateLogMapper operateLogMapper;
// 定义一个环绕通知,当方法上有@Log注解时,此通知会被触发
@Around("@annotation(com.itheima.anno.Log)")
public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
// 操作人ID - 当前登录员工ID
// 获取请求头中的JWT令牌,并解析令牌
String jwt = request.getHeader("token");
Claims claims = JwtUtils.parseJWT(jwt);
Integer operateUser = (Integer) claims.get("id");
// 操作时间
LocalDateTime operateTime = LocalDateTime.now();
// 操作类名
String className = joinPoint.getTarget().getClass().getName();
// 操作方法名
String methodName = joinPoint.getSignature().getName();
// 操作方法参数
Object[] args = joinPoint.getArgs();
String methodParams = Arrays.toString(args);
// 记录方法开始时间
long begin = System.currentTimeMillis();
// 调用原始目标方法运行
Object result = joinPoint.proceed();
// 记录方法结束时间
long end = System.currentTimeMillis();
// 方法返回值
String returnValue = JSONObject.toJSONString(result);
// 操作耗时
Long costTime = end - begin;
// 创建操作日志对象
OperateLog operateLog = new OperateLog(
null, // 主键ID,通常由数据库自动生成
operateUser, // 操作人ID
operateTime, // 操作时间
className, // 操作类名
methodName, // 操作方法名
methodParams, // 操作方法参数
returnValue, // 方法返回值
costTime // 操作耗时
);
// 将操作日志插入数据库
operateLogMapper.insert(operateLog);
// 记录日志信息
log.info("AOP记录操作日志: {}", operateLog);
// 返回原始目标方法的结果
return result;
}
}
案例2
需求:利用aop实现拦截器完成登录校验功能
定义登录自定义标签
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CheckLogin {
}
自定义AOP切面类,拦截被@CheckLogin标识的controller方法
// 使用Lombok的@Slf4j注解自动生成日志对象,方便记录日志
@Slf4j
// 将这个类标记为Spring的一个组件,这样Spring就能管理这个类的生命周期
@Component
// 标记这个类为一个切面,意味着它可以定义横切关注点,比如安全检查、事务管理等
@Aspect
// 设置这个切面的执行顺序,数字越小优先级越高。这里设置为100
@Order(100)
public class CheckLoginAspect {
// 通过Spring的@Autowired注解自动装配RedisTemplate,用于与Redis数据库交互
@Autowired
private RedisTemplate redisTemplate;
// 定义一个环绕通知,当方法上有@CheckLogin注解时,此通知会被触发
@Around("@annotation(checkLogin)")//切面类和注解类在同一个包下。
public Object process(ProceedingJoinPoint joinPoint, CheckLogin checkLogin) throws Throwable {
// 获取当前请求的上下文
RequestAttributes ra = RequestContextHolder.getRequestAttributes();
// 将请求上下文转换为Servlet请求上下文
ServletRequestAttributes sra = (ServletRequestAttributes) ra;
// 从Servlet请求上下文中获取HttpServletRequest对象
HttpServletRequest request = sra.getRequest();
// 从请求头中获取"token"字段的值,该值通常用于验证用户的身份
String token = request.getHeader("token");
// 如果token为空,则抛出异常,提示用户未登录或登录已过期
if (!StringUtils.hasText(token)) {
throw new MyException(ResultCodeEnum.LOGIN_AUTH);
}
// 如果token有效,则继续执行被拦截的方法
return joinPoint.proceed();
}
}
然后把@CheckLogin加载需要拦截的controller方法就可以了
标签:SpringBoot,通知,切入点,AOP,切面,Aop,注解,方法,public From: https://blog.csdn.net/2301_81717523/article/details/142991401