爱你孤身走暗巷 爱你不跪的模样 爱你对峙过绝望 — 《孤勇者》
一. 「为什么需要aop」
在我们的代码中除了写业务代码,还要兼顾系统性的需求,比如权限认证、日志、事务处理等。这些代码独立于CRUD代码,一些热爱编程的同学似乎也能找到一些编程的乐趣。
可是这些系统性的功能写多了,又会陷入复制粘贴的陷阱,散落在各个业务逻辑中,实在是不雅观,专注于CURD的同事也会找上门,臃肿的代码让他们难以维护,比如打印日志,有些需要打印日志的方法只能手动添加,方法分散在不同的包中,还需要一一找出。特别是在debug的过程中,有些方法的日志是临时的,问题解决后也是需要手动清除,这都是编码之外繁重的负担,面对不断变更的需求,编码还来不及,哪还有时间一一手动添加或删除。
本以为写点系统性的功能比CRUD高级,到头来还被CRUDer数落一番,属实郁闷。程序员解闷最好的方式就是读《java编程思想》,在看到java的三大特性之一封装的时候,来了灵感,解决复杂性最好的方式就是封装,行业惯例就是再加一层,应用在这些系统性的功能上就是有一个统一的抽象层专门解决系统性功能,业务代码需要该功能的时候声明一下就可以。就像幼儿园老师发糖果一样,想要棒棒糖的举手,想要彩虹糖的举手,想要风车糖的举手。这就是给代码解耦。
二. 「SpringAOP定义」
如果核心功能是一块面包,aop就是切开面包加进肉饼,这样就组装成了可口的汉堡。在编程世界里,这个肉饼就是权限认证、日志、事务处理等。虽然没有肉饼,面包也能吃,顶多味道差点。
在美食世界里,好吃是很主观的,饿的时候吃什么都香,饱的时候什么都不想吃,因此好吃的定义绝非出自饱汉之口,饿汉面对食物往往会夸大其词,比如,明明是一块饼里抹点肉泥,却称肉夹馍。当然肉夹馍的名字意为“肉馅的夹馍”,非陕西本地人往往只会从字面上理解。
朝鲜冷面在东北地区确实是冷汤冷面的,适合夏天吃。到了徐州当地就是热的,这就是美食在地域上的差异。在徐州喝冷面的同时,会搭配当地特色的肉夹馍,这里的肉夹馍名副其实,馍吃完了,肉还没吃完,每次还得专门提醒一下师傅,少放点肉。
三. SpringAOP体系
应用aop都离不开时间地点事件,也就是在什么时间什么地点做了什么事,比如情人节男生在街上送花虐狗。圣诞节圣诞老爷爷在孩子们的床头袜子里塞满礼物。双十一剁手党们在电商平台剁手。举不胜数。
那么aop的专业术语是怎样解释时间地点事件的呢?简单来说,切点就是在什么地方,通知就是在什么时间做什么事,他们组合起来就是切面,切面就是在什么时间什么地点做了什么事。
这张图中涉及到了接入点的概念,可以这样理解,我们在网上买东西的时候,除了付钱还要提供收货地址,写在快递单上的收货地址就是切点,快递员按收货地址送货上门的地方就是接入点了。一个观念名义上的,一个现实存在地。
按照这个思路,aop的过程就像一次网购送货过程,用户提供收货地址,快递员从包裹上获得送货地址,按照送货平台规定的时间将包裹里的商品,送货上门。这么说有点绕,那就上图,一图胜千言。
四. 一个实例
实例:主要作用是验证参数,有两个参数,可以在一个切面类里验证,也可以通过两个切面类分开验证,多个切面类作用在一个目标方法或类上的场景还是比较普遍,这里就使用两个切面类分别验证参数,借助@Order可以决定两个切面类的执行顺序。
「1.创建一个自定义注解」
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CheckParam {}
「2.创建两个切面类」 切面类1,验证参数id
@Aspect
@Component
@Order(1)
public class CheckIDAdvice {
@Pointcut("@annotation(com.dawang.annotation.CheckParam)")
private void checkId() {
}
@Around("checkId()")
public Object checkIdAround(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] objects = joinPoint.getArgs();
Integer id = (Integer) objects[1];
if(id != null){
System.out.println("切面CheckIDAdvice验证参数id:"+id );
}
return joinPoint.proceed();
}
}
切面类2,验证参数name
@Aspect
@Component
@Order(2)
public class CheckNameAdvice {
@Pointcut("@annotation(com.dawang.annotation.CheckParam)")
private void checkName() {
}
@Around("checkName()")
public Object checkNameAround(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] objects = joinPoint.getArgs();
String name = (String) objects[0];
if(name != null){
System.out.println("切面CheckNameAdvice验证参数name:"+name );
}
return joinPoint.proceed();
}
}
「3.自定义注解作用在目标方法上」
@Service
public class TestServiceImpl implements TestService {
@CheckParam()
public String test(String name, Integer id) {
return "name:"+name + " id:"+id;
}
}
「4.测试」
@RunWith(SpringRunner.class)
@SpringBootTest(classes = SpringaopDemoApplication.class)
class SpringaopTests {
@Autowired
private TestService testService;
@Test
public void test1(){
testService.test("admin",1);
}
}
结果:
五. @Pointcut注解表达式
5.1 「定义」
@Pointcut(value="表达式标签 (表达式格式)")
@Pointcut注解表达式标签有很多比如:execution,within,this,target,args,@within,@target,@args,@annotation,bean,使用最多的就是execution表达式
5.2 「execution表达式」
@Pointcut用来定义切点,借助属性execution()描述目标类或目标方法所在的位置,execution表达式可以直接指定目标类或目标方法,也可以指定目标类和目标方法所在的范围,通常后一种指定范围的方式用途较广。
我有一个同事,最近刚买了房,大家兴趣浓厚,都在打听在哪买的。他本人说买在地铁口。我们问哪个地铁口?他说就是欧尚旁边的那个地铁口。哪边的欧尚?我家旁边的欧尚你家在哪?在地铁口。
在同事眼中地铁口附近,欧尚旁边都是指向家的位置,但是你家又不是我家,我们大家又怎么知道你家到底住在哪? 在计算机世界里就不会原地绕圈子了,比如execution表达式就是定位接入点的位置,赶紧学好execution表达式,帮助迷路的同事回家。
execution表达式的定义execution(<修饰符模式>?<返回类型模式><方法名模式>(<参数模式>)<异常模式>?)除了返回类型模式、方法名模式和参数模式外,其它项都是可选的。
execution表达式三板斧:返回类型模式,方法名模式,参数模式,其他的都是锦上添花,可有可无。这里提到模式是指通配符匹配模式,是因为返回类型、方法名,参数等大多数情况下不会明确的指定确定值,会通过通配符确定值的范围。
常用通配符:
* :匹配任何数量字符
+:匹配指定类型及其子类型;仅能作为后缀放在类型模式后边
.. :匹配任何数量字符的重复,如在类型模式中匹配任何数量子包;而在方法参数模式中匹配任何数量参数(0个或者多个参数)
注意:如果需要匹配子类使用‘+’,如果匹配子包使用‘..*’,这在后面会有涉及。
「5.2.1.方法名模式」
在三板斧里,使用通常使用方法名模式和参数模式进行匹配连接点,而方法名模式又分了三种方式:
- 通过方法签名匹配连接点
- 通过类匹配连接点
- 通过类包匹配连接点
「1.通过方法签名匹配连接点」
在execution表达式中方法签名可通过直接指明匹配或前缀匹配或后缀匹配的方式进行匹配,除了方法签名,三板斧中的其他部分就按通配符的方式书写即可比如:
匹配目标类的test方法
execution表达式:execution( * test(..))
说明:第一个‘*’对应三板斧的返回类型模式,test对应三板斧的方法名模式,‘..’对应三板斧的参数模式
既然说到了方法签名前缀后缀方式,那就匹配目标类所有以util为后缀的方法,那就举个方法签名后缀的例子:execution(* *Util(..))说明:‘*Util’是以Util为后缀的方法,如DateUtil,StringUtil等
「2.通过类匹配连接点」 这里的类匹配,还是属于方法名模式的范围,只是方法名不明确指出,方法所属的全限定类名需要明确写出,全限定类名 = 包名+类名。比如想要匹配Person类下的所有方法
execution表达式:execution( * com.demo.Person.*(..))
说明:第一个‘ ’对应三板斧的返回类型模式,第二个‘’对应三板斧的方法名模式中的任意方法名,‘..’对应三板斧的参数模式 如果Person类还有子类,子类中的方法也要匹配上那么只要在Person类后加上‘+’即可,也就是execution( * com.demo.Person+. (..))
「3.通过类包匹配连接点」
这个类包有点类似于扫描bean时的包路径,也就是在该路径下的类和方法都能匹配到。
比如,想要匹配com.demo包下的所有类和方法
execution表达式:execution(* com.domo.*(..))
说明:第一个‘’对应三板斧的返回类型模式,第二个‘’对应三板斧的方法名模式中的任意类名和方法名,‘..’对应三板斧的参数模式 以上的方式有个缺点,如果com.dome包下还有子包com.dome.controller,那就匹配不上了,针对这种问题,解决方式就是使用‘..’表示本包和子包下的说有类和方法execution表达式:execution( com.domo..*(..))说明:com.domo包下的类和方法可以匹配到,com.domo.controller下的类和方法也可以匹配到。
「5.2.2.参数模式」
1.「 ‘*’和‘..’的使用」
在方法名模式中通过方法签名,类名,包名的方式进行匹配,那么在参数模式中就比较灵活了,通常会通过参数类型进行匹配,在以上的匹配模式中,参数模式都是使用‘..’表示任意类型参数且参数个数不限,除此之外还会使用‘’表示任意类型的参数,参数个数为一个,这点要格外注意。比如execution( test(String,)))和execution( test(String,..)))就不完全等价,前者匹配目标类中的test方法参数只能有两个,第一个是String类型,第二个是任意类型。但是第二个execution表达式的test方法参数可能是一个,也可能是两个,甚至更多,无论是哪个,第一个参数为String类型是确定的,其余参数可以是任意个数和任意类型。
2. 「‘+’的使用」
如果参数的类型是一个引用类型,凡是使用该引用类型的参数都是可以匹配到的,但是它的子类就没有这么幸运了,对于参数模式中明确指定的类型,匹配时就严格按此类型进行匹配,如果既要匹配本类还要匹配子类,那么只能通过‘+’来解决了,在引用类参数后添加‘+’即可,这样既能匹配本类,也能匹配子类。execution表达式:execution(* test(Object+)))说明:Object类是String类的父类,在execution表达式中既能匹配test(Object object),又能匹配test(String str)
案例
匹配任意方法:execution(* *(..))
匹配任何以get开始的方法:execution(* get*(..))
匹配TestService接口的任意方法,在实际执行时,真正匹配的是实现类中实现的方法:execution(* com.dome.TestService.*(..))
匹配TestService接口的任意方法和实现类的所有方法,哪怕实现类中的非实现方法也能匹配:execution(* com.dome.TestService+.*(..)
匹配dao包里的任意方法: execution(* com.domo.dao. . (..))
匹配dao包和所有子包里的任意类的任意方法:execution(* com.demo.dao**...(..))**
5.3 「within表达式」
within表达式中的表达式格式不一定是类型全限定名,可能是一个类或者包,它的作用就是匹配这个类或包下的方法
切点
@Pointcut(within("Person"))
接入点test方法
public class Person{
public void test(String o){}
}
5.4 「this表达式」
我们直到spring aop是通过代理实现的,在实现过程中使用代理对象表示目标对象,那么this表达式中的表达式格式就是代理对象的类型,通常使用JDK动态代理对象,JDK动态代理的特点就是只能对实现了接口的类生成代理,那么这时这个代理对象的类型时一个接口类型,this表达式中的表达式格式就是一个接口,另外还要求这个接口必须是类型全限定名,接口中有方法签名,当有类实现这个接口时,这个类中的方法就是要匹配的目标。
切点
@Pointcut(this(String))
接入点test方法
public class Son implements Person{
public void test(Object o){}
}
public interface Person{
public void test(Object o);
}
5.5 「target表达式」
target表达式中的表达式格式是一个接口,接口中有方法签名,当有类实现了这个接口,类中实现的方法就是匹配的目标。
切点
@Pointcut(target(String))
接入点test方法
public class Son implements Person{
public void test(Object o){}
}
public interface Person{
public void test(Object o);
}
5.6 「args表达式」
args表达式中的表达式格式是参数的类型,这个类型是全限定名的,不支持通配符。目标方法参数类型并非书面书写的类型,只有在运行时才能确定,为了使用args表达式,通常目标方法的参数类型是个父类,表达式格式的参数类型是子类,才能成立,这样在运行时才能确定真实类型的匹配方式比较耗费资源,不建议使用。
切点
@Pointcut(args(String))
接入点test方法
public class Person{
public void test(Object o){}
}
5.7「 @within表达式」
@within表达式中的表达式格式是一个注解的全限定名,如果一个类使用了这个注解,那么这个类中的方法就是需要匹配的目标。值得注意的是,这个注解在定义的没有使用 @Inherited,也就是说明子类不会继承父类的这个注解,那么@within表达式遇到这种情况,也就不会匹配到子类里的方法,只对标注这个注解的父类起作用。
切点
@Pointcut(@within(org.springframework.transaction.annotation.Transactional))
接入点test方法
@Transactional
public class Person{
public void test(){}
}
5.8「@target表达式」
@target表达式中的表达式格式是一个注解的全限定名,如果一个类使用了这个注解,那么这个类中的方法就是需要匹配的目标。面对在定义注解时有没有使用@Inherited,它的处理方式和@within一样。
切点
@Pointcut(@target(org.springframework.transaction.annotation.Transactional))
接入点test方法
@Transactional
public class Person{
public void test(){}
}
5.9「@args表达式」
@args表达式中的表达式格式是一个注解的全限定名,如果一个方法的参数是一个类,并且这个在定义的时候使用了这个注解,那么这个方法就会被匹配到。注意,这个方法方法必须只有一个参数,还有注解时标注在参数类的原始定义上,不是标注在这个方法参数上。
切点
@Pointcut(@args(org.springframework.transaction.annotation.Transactional))
接入点test方法
public void test(Person p){}
Person类定义
@Transactional
public class Person(){}
5.10「@annotation表达式」
@annotation表达式中的表达式格式是一个注解的全限定名,这次不再遮遮掩掩,只要方法上标注了这个注解都能匹配到,用途较广。
切点
@Pointcut(@annotation(org.springframework.transaction.annotation.Transactional))
接入点test方法
@Transactional
public void test(){}
5.11「bean表达式」
bean表达式中的表达式格式是一个bean的字符串名字,这个名字可以使用通配符,如果容器中的bean的名字匹配表达式,那么bean中的方法就是匹配的目标。
切点
@Pointcut(bean("testService"))
接入点test方法
@Service("testService")
public class TestService{
public void test(){}
}
六. 5种通知类型
spring有5种不同的通知类型,分别是前置通知,返回后通知,抛出异常后通知,后置通知和环绕通知。
-
「前置通知」
它是一个使用@Before标注的增强方法,在目标业务方法之前执行
-
「返回后通知」
它是一个使用@AfterReturning标注的增强方法,在目标方法正常返回后执行,这里的正常方法是指目标方法执行没有发生异常。如果如果目标方法发生了异常就使用“抛出异常后通知”
-
「抛出异常后通知」
它是一个使用@AfterThrowing标注的增强方法,在目标方法执行时发生异常时执行。
-
「后置通知」
它是一个使用@After标注的增强方法,无论目标方法是否发生了异常,这个增强方法都会被执行。
-
「环绕通知」
它是一个使用@Around标注的增强方法,在这个增强方法里可以调用执行目标方法,这样在目标方法的前后可以添加一些处理逻辑,这就是所谓的环绕。这就提供了比较大的发挥空间,我们可以决定它的执行时间,可以改变它的参数值,可以改变目标方法的返回值,甚至阻止目标方法的执行。这个环绕通知集中了其他几个增强通知的特性,功能很强大,对于一些简单的通知,杀鸡焉用牛刀,交给其他通知方法处理吧。
七. 5种通知类型传递参数
当增强方法作用在目标方法上时,如果目标方法还有参数传递,那么在增强方法中怎么获得这些参数?
其实,每个增强方法都是可以携带任意参数的,通常第一个参数的类型是JoinPoint类型,顾名思义就是连接点目标方法,这个JoinPoint类中定义了 getArgs方法,可以返回目标方法的参数。
值得注意的是,在环绕通知里,第一个参数的类型是ProceedingJoinPoint,无需惊慌,这只是JoinPoint的子类,仍可使用getArgs方法获得目标方法的参数。
- 「前置通知」
@Before("pointcut()")
public void beforeOne(JoinPoint point){
System.out.println("目标方法的参数:" + Arrays.toString(point.getArgs()));
}
-
「返回后通知」
@AfterReturning属性returning的值需和参数列表的形参保持一致
@AfterReturning(value="pointcut()", returning="returnValue")
public void afterReturningOne(JoinPoint point, Object returnValue){
System.out.println("目标方法的参数:" + Arrays.toString(point.getArgs()));
System.out.println("返回值:" + returnValue);
}
-
「抛出异常后通知」
@AfterThrowing属性throwing的值需和参数列表的形参保持一致形参的类型声明为Throwable,意味着对目标方法抛出的异常不加限制
@AfterThrowing(value="pointcut()", throwing="ex")
public void afterThrowingOne(JoinPoint point, Throwable ex){
System.out.println("目标方法的参数:" + Arrays.toString(point.getArgs()));
System.out.println("异常:" + ex);
}
- 「后置通知」
@After(value="pointcut()")
public void afterOne(JoinPoint point){
System.out.println("目标方法的参数:" + Arrays.toString(point.getArgs()));
}
-
「环绕通知」
这里必须返回目标方法的调用值
@Around(value="pointcut()")
public Object process(ProceedingJoinPoint point) throws Throwable{
System.out.println("目标方法的参数:" + Arrays.toString(point.getArgs()));
return point.proceed(args);
}
-
「彩蛋」
除了以上使用JoinPoint的方式获取目标方法的参数,其实还有更简便的方式:args表达式 比如,前置通知使用args表达式获得目标方法参数这里需要注意,args表达式里的格式应该是类型的声明,为何这里成了自定义的参数变量,无需惊慌,自定义的参数变量对应方法参数里的形参,它所需要的类型声明,转移到了形参里的类型声明。这样做的好处是不仅可以按参数类型匹配目标方法,还可以获得目标方法的参数值,一举两得。
@Before("pointcut() && args(name, id)")
public void beforeOne(String name,int id){
System.out.println("目标方法的参数:name:" + name+" id:"+id);
}
八 . 总结
脸皮厚能吃肉,脸皮薄吃不着。使用springaop很多年了,这次厚着脸皮给大家再说道说道。
- 为什么需要aop,弥补oop的不足
- SpringAOP的定义,切点,接入点,通知,切面等几大技术术语的解析
- SpringAOP体系,aop的执行过程
- 一个实例,一个验证参数的例子,涉及到自定义注解
- @Pointcut注解表达式,涉及到10种匹配接入点的方式
- 5种通知类型,前置通知,后置通知,环绕通知,返回后通知,抛出异常后通知
- 5种通知类型传递参数,如何在增强方法中获得目标方法的参数
最后奉上本文涉及到的源码程序员句柄/spring-demo-all (gitee.com),不止是源码,有彩蛋!
经历的痛苦愈多,体会到的喜悦就愈多。 我是句柄,我们下期见!