什么是AOP?
AOP(Aspect-Oriented Programming,面向切面编程)是一种编程范式,它的主要目的是通过横切关注点(cross-cutting concerns)来提高代码的模块性和可维护性。在AOP中,关注点是一个应用程序中横跨多个模块的功能,如日志、事务管理、安全性等。AOP通过在关注点周围编织(weaving)切面(aspect)来实现这些功能,而不是在主要业务逻辑中直接插入这些功能代码。(ps:不想看理论可直接跳到-->“实战”)
核心概念:
-
切面(Aspect): 切面是关注点的模块化体现,它包含了与关注点相关的一组通知(advice)。在AOP中,通知定义了在何时、何地执行特定的代码逻辑。
-
连接点(Join Point): 连接点是在应用执行过程中可以插入切面的点。它是程序执行的特定位置,例如方法调用、异常抛出或字段访问等。
-
通知(Advice): 通知是切面的具体行为,它定义了在连接点何时执行什么代码。通知的类型包括前置通知(before)、后置通知(after)、返回通知(after-returning)、异常通知(after-throwing)和环绕通知(around)。
-
切点(Pointcut): 切点是一组连接点的集合,它定义了切面在何处执行。切点使用表达式语言来匹配连接点。
-
引入(Introduction): 引入允许在现有类上添加新的方法或属性。
由上可知,AOP其实可以算作面向方法编程,那什么又是面向方法编程呢,为什么又需要面向方法编程呢?来我们举个简单且通俗易懂的例子做一个说明:
假设我们的代码是一整个巨大的吐司面包,诶,谁好人早饭吃吐司啃着吃啊,一点都不优雅。我们把它切成片,我想怎么切怎么切,切开之后,我在其中一片上,挤上果酱。我又拿起来一片,我给他上面放一片火腿。好,当你脑海中想象这样的场景的时候,你就已经懂了AOP的核心概念。
通俗易懂来讲,我们把代码或方法片段视为‘一片面包’,我们可以在这片面包的上面或下面甚至上下两面进行操作。假设你有一个工厂,需要给每片面包上面涂果酱下面涂番茄酱,只需要配置成自动化即可,无需人工一片一片涂,设置一次,即可实现全体自动化。大大减轻了人工的负担。
现在我们来探讨一下实际应用场景。
假设我们开发了一个比较严谨的程序,我们有多张表,在我们每一次进行增和改的时候,我们都需要录入updateTime(修改时间)和updateBy(修改人),哇,每写一次都要增改就要set一次time和user,如果一百张表,每个表都有增和改的实现方法,那就要set几百次。这能行吗,这指定不能行。
再比如,我们需要知道一段代码的执行时间,其实只需要在最开始获取一个时间戳,在结尾再获取一个时间戳,结尾减去开头,那就是这段代码的执行时间。以上分析的实现方式是可以解决需求问题的。但是对于一个项目来讲,里面会包含很多的业务模块,每个业务模块又包含很多增删改查的方法,如果我们要在每一个模块下的业务方法中,添加记录开始时间、结束时间、计算执行耗时的代码,就会让程序员的工作变得非常繁琐。
而AOP面向方法编程,就可以做到在不改动这些原始方法的基础上,针对特定的方法进行功能的增强。
AOP的作用:在程序运行期间在不修改源代码的基础上对已有方法进行增强(无侵入性: 解耦)
我们要想完成统计各个业务方法执行耗时的需求,我们只需要定义一个模板方法,将记录方法执行耗时这一部分公共的逻辑代码,定义在模板方法当中,在这个方法开始运行之前,来记录这个方法运行的开始时间,在方法结束运行的时候,再来记录方法运行的结束时间,中间就来运行原始的业务方法。
而大家会发现,这个流程,我们是不是似曾相识啊?
其实和动态代理技术是非常类似的。 我们所说的模板方法,其实就是代理对象中所定义的方法,那代理对象中的方法以及根据对应的业务需要, 完成了对应的业务功能,当运行原始业务方法时,就会运行代理对象中的方法,从而实现统计业务方法执行耗时的操作。
其实,AOP面向切面编程和OOP面向对象编程一样,它们都仅仅是一种编程思想,而动态代理技术是这种思想最主流的实现方式。而Spring的AOP是Spring框架的高级技术,旨在管理bean对象的过程中底层使用动态代理机制,对特定的方法进行编程(功能增强)。
AOP的优势:
减少重复代码
提高开发效率
维护方便
现在,我们首先快速入门,然后再深入探究结合到项目中。
入门案例:
首先就是引入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
然后新建一个文件夹,命名为:aspect,在里面新建一个JAVA文件:TimeAspect
@Component
@Aspect //声明当前类为切面类
@Slf4j
public class TimeAspect {
@Around("execution(* com.ycg.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;
}
}
重新启动SpringBoot服务测试程序:
并且执行一条查询语句
我们看到,在不修改源代码的基础上,已经实现了计算时间逻辑。
我们通过AOP入门程序完成了业务方法执行耗时的统计,那其实AOP的功能远不止于此,常见的应用场景如下:
记录系统的操作日志
权限控制
事务管理:我们前面所讲解的Spring事务管理,底层其实也是通过AOP来实现的,只要添加@Transactional注解之后,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的核心概念我们介绍完毕之后,接下来我们再来分析一下我们所定义的通知是如何与目标对象结合在一起,对目标对象当中的方法进行功能增强的。
Spring的AOP底层是基于动态代理技术来实现的,也就是说在程序运行的时候,会自动的基于动态代理技术为目标对象生成一个对应的代理对象。在代理对象当中就会对目标对象当中的原始方法进行功能的增强。
这个时候已经无需在深入了,我们再把问题抛回明面,相必大家也是想用更通俗易懂的语言,最简单的例子去了解,ok我们现在开始。
首先是AOP的类型:
@Around:环绕通知,此注解标注的通知方法在目标方法前、后都被执行
@Before:前置通知,此注解标注的通知方法在目标方法前被执行
@After :后置通知,此注解标注的通知方法在目标方法后被执行,无论是否有异常都会执行
@AfterReturning : 返回后通知,此注解标注的通知方法在目标方法后被执行,有异常不会执行
@AfterThrowing : 异常后通知,此注解标注的通知方法发生异常后执行
什么?记不住,根本记不住,叮又叮不懂,鞋也鞋不费。
其实最常用的,是before和around
上面我们已经以around为例讲了如何实现自动计算运行时间,接下来我们使用before为例。
假设有一个场景,就是我们需要在每次修改或新增之后都需要set一个updateTime和updateBy,
其实这时候你可能想说,这有什么难的,不就四行代码吗,但是你切记,你不可能只有一个方法、一张表。
实战:
首先,我们先贴出来代码,然后再逐行去解释每一行代码的意思:
package com.ycg.vue.aspect;
import com.ycg.vue.Annotation.AutoInsert;
import com.ycg.vue.constant.AspectConstant;
import com.ycg.vue.context.BaseContext;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.Objects;
import java.util.UUID;
/**
* @Description AOP切片实现类
* @Author jink
* @Date 2024/1/18
*/
@Slf4j
@Component
@Aspect
public class AutoInsertEntity {
@Before(value = "execution(* com.ycg..*.mapper.*.*(..)) && @annotation(com.ycg.vue.Annotation.AutoInsert)")
public void AutoInsert(JoinPoint joinPoint) {
log.info("<===== AutoInsert start =====>");
//获取参数
Object[] args = joinPoint.getArgs();
if (Objects.isNull(args) || args.length == 0) {
log.warn("<===== AutoInsert warn:Not Find Args =====>");
log.info("<===== AutoInsert end =====>");
return;
}
//得到第一个参数,这也意味着,如果参数不止一个,那么只能取第一个,因此,进入切片的方法,第一个参数,必须是实体类
Object entity = args[0];
//获取方法签名
MethodSignature methodsMetadata = (MethodSignature) joinPoint.getSignature();
//获取方法
Method method = methodsMetadata.getMethod();
//获取注解
AutoInsert annotation = method.getAnnotation(AutoInsert.class);
Date now = new Date();
try {
//判断注解类型
switch(annotation.value()){
case INSERT:{
log.info("<===== AutoInsert Type:INSERT =====>");
//使用反射,给实体类赋值
//AspectConstant.SET_ID 是自定义的常量类,下文会给出代码
entity.getClass().getDeclaredMethod(AspectConstant.SET_ID, String.class).invoke(entity, UUID.randomUUID().toString().replaceAll("-",""));
entity.getClass().getDeclaredMethod(AspectConstant.SET_CREATE_BY, String.class).invoke(entity, BaseContext.getUserId());
entity.getClass().getDeclaredMethod(AspectConstant.SET_UPDATE_BY, String.class).invoke(entity, BaseContext.getUserId());
entity.getClass().getDeclaredMethod(AspectConstant.SET_CREATE_TIME, Data.class).invoke(entity, now);
entity.getClass().getDeclaredMethod(AspectConstant.SET_UPDATE_TIME, Data.class).invoke(entity, now);
break;
}
case UPDATE:{
log.info("<===== AutoInsert Type:UPDATE =====>");
entity.getClass().getDeclaredMethod(AspectConstant.SET_UPDATE_TIME, Data.class).invoke(entity, now);
entity.getClass().getDeclaredMethod(AspectConstant.SET_UPDATE_BY, String.class).invoke(entity, BaseContext.getUserId());
break;
}
case REGISTER: {
log.info("<===== AutoInsert Type:REGISTER =====>");
String id = UUID.randomUUID().toString().replaceAll("-","");
entity.getClass().getDeclaredMethod(AspectConstant.SET_ID, String.class).invoke(entity, id);
entity.getClass().getDeclaredMethod(AspectConstant.SET_CREATE_BY, String.class).invoke(entity,id);
entity.getClass().getDeclaredMethod(AspectConstant.SET_UPDATE_BY, String.class).invoke(entity,id);
entity.getClass().getDeclaredMethod(AspectConstant.SET_CREATE_TIME, java.util.Date.class).invoke(entity, now);
entity.getClass().getDeclaredMethod(AspectConstant.SET_UPDATE_TIME, java.util.Date.class).invoke(entity, now);
break;
}
}
log.info("<===== AutoInsert end =====>");
log.info("<===== AutoInsert success =====>");
} catch (Exception e) {
log.error("<===== AutoInsert error =====>");
throw new RuntimeException(e);
}
}
}
自定义常量类:
package com.ycg.vue.constant;
public interface AspectConstant {
public static final String SET_CREATE_TIME = "setCreateTime";
public static final String SET_UPDATE_TIME = "setUpdateTime";
public static final String SET_CREATE_BY = "setCreateBy";
public static final String SET_UPDATE_BY = "setUpdateBy";
public static final String SET_ID = "setId";
}
其实,我已经把注释写的非常清晰了,现在讲其中较为重要的部分:
首先:
这一坨是什么?
在上面已经看到好几次了,这一串神秘的代码是什么意思。
首先前面的@Before,大家肯定知道,这是声明这个切片是在目标方法执行前执行。
那后面的value呢,你切记,value必须有,他声明了此切片的目标 ,即上面提到的切入点表达式。
切入点表达式分为两种,分别是路径表达式(execution)和自定义注解表达式(@annotation)
路径表达式:
execution主要根据方法的返回值、包名、类名、方法名、方法参数等信息来匹配,语法为:
execution(访问修饰符? 返回值 包名.类名.?方法名(方法参数) throws 异常?)
其中带?
的表示可以省略的部分
访问修饰符:可省略(比如: public、protected)
包名.类名: 可省略
throws 异常:可省略(注意是方法上声明抛出的异常,不是实际抛出的异常)
示例:
@Before("execution(void com.ycg.service.impl.DeptServiceImpl.delete(java.lang.Integer))")
可以使用通配符描述切入点
*
:单个独立的任意符号,可以通配任意返回值、包名、类名、方法名、任意类型的一个参数,也可以通配包、类、方法名的一部分
..
:多个连续的任意符号,可以通配任意层级的包,或任意类型、任意个数的参数
切入点表达式的语法规则:
-
方法的访问修饰符可以省略
-
返回值可以使用
*
号代替(任意返回值类型) -
包名可以使用
*
号代替,代表任意包(一层包使用一个*
) -
使用
..
配置包名,标识此包以及此包下的所有子包 -
类名可以使用
*
号代替,标识任意类 -
方法名可以使用
*
号代替,表示任意方法 -
可以使用
*
配置参数,一个任意类型的参数 -
可以使用
..
配置参数,任意个任意类型的参数
切入点表达式示例
-
省略方法的修饰符号
execution(void com.ycg.service.impl.DeptServiceImpl.delete(java.lang.Integer))
-
使用
*
代替返回值类型execution(* com.ycg.service.impl.DeptServiceImpl.delete(java.lang.Integer))
-
使用
*
代替包名(一层包使用一个*
)execution(* com.ycg.*.*.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.ycg.service.impl.DeptServiceImpl.delete(*))
-
使用
..
省略参数execution(* com..*.*(..))
注意事项:
-
根据业务需要,可以使用 且(&&)、或(||)、非(!) 来组合比较复杂的切入点表达式。
execution(* com.ycg.service.DeptService.list(..)) || execution(* com.ycg.service.DeptService.delete(..))
自定义注解表达式:
通常为了严谨,也为了省略那一串看起来头疼的路径规则,我们还使用自定义注解来指定。
@Before(value = "@annotation(com.ycg.vue.Annotation.AutoInsert)")
里面的参数呢,其实就是你自己的自定义的注解路径。我的路径是这样的:
怎么使用呢,把这个自定义注解添加到需要切片的方法上即可。例如,我有一个注册的方法,也就是需要进行增操作,所以直接把该方法加在mapper(dao)层的方法上即可:
是不是比路径简单多了,只需要一个自定义注解即可,无需匹配路径。
我们看到,注解中又有一个value,而这个value则是我自定义的一个枚举类,里面包含了不同的操作,比如:增?改?等,主要是为了方便后续操作。自定义注解以及枚举类如下:
package com.ycg.vue.Annotation;
import com.ycg.vue.Enum.AutoInsertType;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
/**
* @Description 自定义注解标记方法是否需要自动插入
* @Author jink
* @Date 2024/1/18
*/
@Target(value = {java.lang.annotation.ElementType.METHOD})
@Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME)
public @interface AutoInsert {
AutoInsertType value();
}
package com.ycg.vue.Enum;
/**
* @Description AOP自动插入类型
* @Author jink
* @Date 2024/1/18
*/
public enum AutoInsertType {
INSERT,
UPDATE,
REGISTER,
}
到现在需要配置的就已经全部完成了,我们现在发送一个请求查看效果:
我们看到,在发送的时候我并没有传修改时间或修改人等参数,这时在后台打一个断点,点击发送:
执行成功!已经自动将创建时间、创建人等字段自动赋值。
其实整个过程非常简单,创建切面类-->使用路径或自定义注解声明切入点(也可以像实战中的,两者都使用)--> 编写切面逻辑 。
在实战中,首先方法在执行到mapper(dao)层的时候。
检测到有一个自定义注解,此时自定义注解被激活并被aspect扫描到
前面用来获取并解析被扫描到的方法,最下面一步是获取注解,然后再根据注解的value值,去判断需要进行什么样的操作,由于这是一个注册的操作,所以使用switch进行判断,进入case:REGISTER中:
执行相关语句即可,至此,全部完成。
到这时候我才发现,我忘记写break了,用jdk17习惯了,都用增强switch,不需要写break,考虑到部分开发者jdk版本不高,没用增强switch,结果就给break搞丢了,所以说,编程一定一定要细心!
祝愿大家在编程的路上一去不复返,纵使困顿难行 亦当砥砺奋进!
好,到这里就已经全部结束了。需要项目源码,留言即可。初学java,文章略显稚嫩。欢迎各位大佬指正。