快递100 是专业的快递物流互联网平台,为数以亿计的用户提供快递物流的查、寄件服务。快递100 的各种服务已经稳定高效的运行了多年,为中国的快递物流行业的快速发展做出了很大的贡献,这一切都离不开背后默默贡献的程序猿们。
从第一个独立站点上线至今,快递100 已经走过了10个年头,10年沧海桑田,10年经历了一次又一次的技术、架构升级。近年来,由于spring-boot、spring-cloud技术的兴起,快递100 技术团队也开始对后端服务进行重构,基于spring-boot、spring-cloud技术,对原有的系统进行微服务化改造。在改造的前期,由于许多开发人员对spring系列的技术缺少系统性的认知,所以我们编辑整理了一个spring系列技术使用的手册,帮助开发人员可以快速的上手。
文章目录
1. Spring AOP简介
AOP是Aspect Oriented Programming(面向切面编程)的简写。与传统OOP自上而下的开发模式相比,AOP是一种“横向”的编程技术,其解决的问题,是OOP自上而下的编程方式所不能解决的横切性的问题。
举个例子,假如我们有两个业务逻辑模块A和B,在处理这两个模块的数据更新时,均需要经过:①获取连接->②开启事务->③执行业务数据更新操作->④记录日志->⑥提交(回滚)事务 等几个步骤,这几个步骤中,除了“③执行业务更新操作”是各个业务模块所独有的操作,其他几个步骤,对于不同的业务模块来说都是可以通用的。基于OOP的设计方式,只能够将这些步骤对应的操作封装成对象,然后在A、B两个业务模块中去进行调用。比如开启和提交、回滚事务的操作,可以封装成事务管理器(TransactionManager)对象,提供beginTransaction()、commitTransaction()、rollbackTransaction()方法,在A、B的业务代码中分别调用,这会导致事务管理的代码散落在不同的模块,增加后面维护的难度,同时这也会使业务模块的关注点偏离自身的核心业务。
AOP正是为了解决这样的问题而设计,在AOP的编程模式中,通过对应用系统进行横向的切割,将系统中的不同业务模块共同的部分进行抽象,形成横向的切面,将类似记录日志、事务管理这些各个业务逻辑处理中都涉及到的部分,集中到切面中进行处理,方便进行维护,同时也让业务模块将关注点集中到自身的业务逻辑。
Spring AOP是spring框架的两大核心理念之一(另外一个是IoC),spring生态中许多组件,都是基于Spring AOP的理念,将一些通用的功能从业务代码中分离出来,在无需侵入应用代码的情况下,对应用代码进行增强。Spring AOP基于IoC容器,通过Bean的后置处理器(BeanPostProcessor接口实现类)对满足切面逻辑的Bean进行增强处理,实现AOP。
2. 相关名词说明
2.1 连接点(Joinpoint)
连接点是指程序运行中的特定位置,如类开始初始化、初始化后、方法调用前、方法调用后、方法返回前、方法抛出异常前等等,连接点是程序代码中拥有特定边界性质的特定的点,AOP编程可以通过连接点对业务代码进行增强。Spring AOP仅支持方法级别的连接点,只能在方法调用前、方法调用后、方法返回前、方法抛出异常和方法调用前后这几个连接点对应用代码进行增强。
连接点由两个信息确定:一是用方法表示的程序的执行点,二是用相对点表示的方位。
2.2 切点(Pointcut)
切点是指AOP编程是对特定连接点的定位。程序中每个类都有很多个连接点,比如一个类拥有多个方法,每个方法都是一个连接点,但并不是每个连接点都需要进行增强,因此AOP中通过切点来描述需要进行增强的连接点。
切点和连接点不是一一对应的关系,一个切点可以匹配多个连接点,Spring AOP中,切点通过org.springframework.aop.PointCut接口实例进行描述,使用类和方法来定位连接点。但由于只能定位到方法,而连接点是方法执行前、执行后、返回前、抛出异常前和执行前后,因此还需要方位信息,才能定位到对应的连接点。
2.3 通知(Advice)
通知是织入到连接点的一段代码,是AOP编程中需要业务实现的主体部分。除此之外,在Spring AOP中,通知还包含方位信息,这样,结合切点的信息,就可以定位到具体的连接点。简单来说,就是切点可以定位到方法,结合不同的通知类型,就可以定位到方法中不同的连接点。如前置通知定位到方法执行前的连接点,后置通知则可以定位到方法执行后的连接点。
Spring AOP支持的通知有如下几种:
- 前置通知:在方法调用前的连接点增强
- 后置通知:在方法调用后的连接点增强
- 异常通知:在方法抛出异常后的连接点增强
- 返回通知:在方法返回后的连接点增强
- 环绕通知:在方法调用前后的连接点增强
从几种通知的描述我们可以看到,通知的类型本身,就包含了方位信息,在切点定位到方法的前提下,结合通知的方位信息,就可以定位连接点。
2.4 目标对象(Target)
目标对象是指AOP需要进行增强的目标类的实例。在没有AOP支持的情况下,目标类需要自行实现所有的细节,而通过AOP的方式增强后,目标类只需要关注自身的核心业务,而不需要关注其他的细节,这些都通过AOP框架对目标对象的增强来实现。
2.5 引介(Introduction)
引介是一种特殊的增强方式,指的是为目标类增加一些属性和方法,即使在业务类没有实现某些接口的情况下,通过引介,也可以动态的为这个业务类添加指定接口的实现逻辑,让业务类成为这个接口的实现类。
2.5 织入(Weaing)
织入是将通知、引介等对于目标类的增强添加到连接点的过程。AOP有三种织入技术,分别是编译器织入(需要特殊的编译器支持)、类装载织入(需要特殊的类装载器)和动态代理织入,Spring AOP采用的是动态代理织入,动态代理织入是在运行期通过动态代理的方式,为目标类创建增强后的子类。
2.6 代理(Proxy)
对目标类进行增强后,就产生了一个新的类,这个类可能是和目标类实现同一个接口的类(接口代理),也可能是目标类的子类(类代理),这个类的实例可以向目标类对象一样进行调用,这就是代理。
2.7 切面(Aspect)
切面是由切点和通知、引介组成的,既包含横切逻辑的定义,也包含连接点的定义,而Spring AOP框架实际上就是负责切面的实施,将切面定义的横切逻辑,织入到切点指定的切入点。
2.8 代理工厂
代理工厂用于创建通过对目标对象织入通知、引介之后的代理对象。Spring AOP创建代理有两种实现方式,一种是基于JDK的动态代理,另一种是基于Cglib框架的动态代理。由于JDK的动态代理是基于接口的代理,只能创建接口的代理实例而不能创建类的代理实例,因此在需要基于类进行代理的情况下,只能使用Cglib。Spring AOP中默认的代理工厂实现是DefaultProxyFactory,在这个实现中,默认使用的代理方式是Cglib,只有在配置了spring.aop.proxy-target-class=false的情况下,或者应用中不存在需要对类进行代理的情况下,才会切换到JDK的动态代理。
3. Spring AOP主要注解
Spring AOP是基于IoC容器实现的,本质上是通过一个BeanPostProcessor,对IoC容器装配的Bean进行增强,在业务代码的连接点织入通知,然后通过代理工厂创建代理实例注入到Spring IoC容器中。这样,通过IoC容器获取到的Bean是经过增强之后的代理实例。
Spring AOP可以通过Xml或者注解进行配置,Spring boot中推荐使用注解,因此我们在这里也只介绍基于注解的配置方式。关于Xml配置的介绍,读者可以自行查阅相关资料,熟悉了AOP的相关知识后,对理解Xml配置方式也同样是很有帮助的。
3.1 @Ascept注解
@Ascept注解用于标注类,表明这个类用于定义描述一个切面,对应于Xml配置文件中的aop-aspect节点。AOP解析引擎会对标注了@Ascept注解的类进行解析,获得其中配置切点和通知,根据切点和通知的信息,定位到需要进行增强的Bean,将对应的通知织入到对应的连接点。
@Ascept注解只有一个配置属性value,用于指定切面的id。
因为Spring AOP是基于IoC容器的,因此@Aspect注解需要搭配@Component注解(或其变体)使用才能生效。当标注的类在框架扫描范围内时,IoC容器对包含@Aspect的类进行装配,AOP通过Bean的后置处理器处理AOP的各种注解,完成对目标对象的增强。
3.2 @Pointcut注解
@Pointcut注解用于标注包含@Aspect注解的类中的方法,为切面配置切点,对应于Xml配置文件中的aop-pointcut节点。被@Pointcut注解标注的方法,通常是一个空方法,但是可以包含参数。
@Pointcut注解包含的配置项如下:
- value:指定切点的表达式,AOP解析引擎会通过解析这个表达式,定位到需要切入的方法
- argNames:配置目标方法参数和切点方法参数之间的映射关系。如下图所示,在切点方法1(pointcutMethod1)和切点方法2(pointcutMethod2)中均@Pointcut注解配置切点表达式,描述的切入点指向目标方法targetMethod。切点方法1中@Poincut注解中配置了argNames=“a,b”,表示按照目标方法的参数顺序,将其参数和切点方法的参数进行映射,第一个参数(str1)映射为切点方法的参数a,第二个参数(str2)映射到切点方法参数b。而切点方法2没有配置argNames,这种情况下,如果编译器设置了在class文件生成变量的调试信息,那么Spring AOP解析引擎会根据获取目标方法和切点方法的参数名,根据名称进行映射,即将目标方法的str1和str2参数分别映射到切点方法2的str1和str2参数。因此,argNames参数并不是必须的,只要切点方法参数名和目标方法参数名一致,框架就会进行映射,但前提是编译器需要设置在class文件中生成变量的调试信息。如果没有设置,框架无法获取到方法的参数名称,就不能够根据方法参数名进行映射,这时候框架会根据参数的类型来尝试映射,但如果参数列表中包含有相同类型的参数,框架无法分辨,就会抛出异常。
//目标方法
public void targetMethod(String str1, String str2) {
System.out.println("目标方法:" + str2 + " " + str2);
}
//切点方法1
@Pointcut(value = "execution(* *.targetMethod(..))",argNames = "a,b")
public void pointcutMethod1(String a, String b) {
}
//切点方法2
@Pointcut(value = "execution(* *.targetMethod(..))")
public void pointcutMethod2(String str1, String str2) {
}
3.3 通知注解
Spring AOP支持五种通知,这些通知都有对应的注解,可以用来标注使用@Aspect注解标注的类中的方法,这些通知注解可以指定切点描述,Spring AOP解析引擎在解析切面类时,会根据这些注解的标注,获取对应的切点,定位到目标类的方法,然后再根据通知注解的类型所表示的方位信息定位到具体的连接点,将通知注解所标注的方法中的代码织入到目标类,完成对目标对象的增强。
Spring AOP中包含的五种通知注解及其说明如下:
- @Before注解:标注前置通知,方位为方法调用前连接点
- @After注解:标注后置通知,方位为方法调用后连接点
- @AfterReturning注解:标注返回通知,方位为方法返回后连接点
- @AfterThrowing注解:标注异常通知,方位为方法抛出异常后连接点
这些直接包含的配置项及其意义都是相同的,这些配置项如下:
- value:指定通知对应的切点,可以设置为方法签名指定同一个类中的切点方法,引入切点方法配置的切点描述,也可以直接设置为切点表达式
- argNames:与@Pointcut中argNames功能一致,配置目标方法参数和通知方法参数的映射
通知方法中的参数除了传入目标方法的参数外,还可以添加一个额外的JoinPoint类型的参数,但这个类型的参数必须放置在通知方法参数的第一位,如下图所示:
//切点方法
@Pointcut(value = "execution(* *.targetMethod(..))", argNames = "a,b")
public void pointcutMethod(String a, String b) {
}
//通知方法
@Before("pointcutMethod(String, String)")
public void beforeMethod(Joinpoint joinpoint, String a, String b) {
}
框架在进行增强处理时,会根据通知方法的参数列表,将目标方法调用的参数传递给通知方法,传递的方式如argNames配置的描述中所示,这里不再重述。如果通知方法的参数列表中包含JoinPoint类型的参数,且位于参数列表的第一位,那么框架会将连接点的信息封装成JoinPoint对象,在代理实例的方法被调用时,将其传递给通知方法。通知方法中,可以通过JoinPoint对象获取到连接点的信息。
JoinPoint中主要方法及其功能说明如下:
- getSignature:返回封装连接点签名信息的Signature对象,因为Spring AOP只支持方法级别的连接点,因此此对象实际是一个MethodSignature类型的对象,在实际应用中可以将其强制转换为MethodSignature类型对象,通过该对象,可以获取到连接点的方法(Method对象)、方法参数、方法参数类型、方法返回值、方法异常列表等信息
- getArgs:返回传入目标方法的参数
- getTarget:返回目标对象
- getThis:返回增强后的代理对象
对于@Around注解,框架封装的是一个ProceedingJoinPoint对象,ProceedingJoinPoint是JoinPoint的扩展,在JoinPoint的基础上,增加了两个proceed方法重载。对应环绕通知,需要在通知方法中调用ProceedingJoinPoint对象的proceed方法,来调用目标方法,否则目标方法不会被调用。其中,调用不带参数的proceed方法表示以原有的参数调用目标方法,而调用带参数的proceed方法则表示使用新的参数调用目标方法。
3.5 通知执行的顺序
如果同一个目标方法配置了多种类型的通知,这些通知执行和目标方法调用的顺序如下:
→①执行前置通知
→②执行环绕通知在调用目标方法前的代码
→③目标方法调用
→④执行环绕通知在调用目标方法后的代码
→⑤未抛出异常执行后置通知,抛出异常则执行异常通知
→⑥执行返回通知
如果同一个目标方法配置了多个同一种类型的通知,默认情况下,这些通知的执行顺序是不可知的,实际使用时可以在通知方法中加入@Order注解,标注通知执行的优先级。@Order注解只有一个配置项value,通过配置一个int型的数字来表示优先级,数字越小,优先级越高,对于前置通知,执行的顺序按优先级从高到低的顺序执行;而对于后置通知、返回通知和异常通知,则是按照优先级从低到高的顺序执行。@Order注解中value默认值为Integer.MAX_VALUE,表示有最低的优先级。
对于环绕通知,则要注意的是因为环绕通知包含了调用目标方法的代码,因此如果存在多个环绕通知的话,这多个环绕通知实际上是一个个嵌套执行的,就像俄罗斯套娃一样,一个环绕通知套一个环绕通知,直到真正执行目标方法调用的那个为止。优先级最高的环绕通知在最外层,优先级最低的环绕通知在最外层,所以如果我们以真正的目标方法被调用作为分界线的话,那么在这个分界线之前,环绕通知的代码是按优先级高到优先级低的顺序,而这个分界线之后,环绕通知的代码则是按优先级低到优先级高的顺序执行。
4. 切点表达式
在@Pointcut注解或者通知注解中,通过切点表达式来描述切点,Spring AOP解析引擎会解析切点表达式,定位到对应的类和方法。
4.1 通配符和操作符
Spring AOP切点表达式支持下面三种通配符:
- “*”:匹配任何数量的字符
- “..”:匹配任何数量字符的重复,在类型模式匹配中表示匹配任何数量的子包;在方法参数模式匹配中表示匹配任何数量的方法参数
- “+”:匹配指定类型的子类型,只能放置在类型匹配模式的后面
基于通配符,可以通过灵活的匹配模式来匹配执行方法连接点,除此之外,切点表达式还支持运算符,用于组合多个切点函数。切点表达式支持的运算符如下:
- 与操作符:“&&”或者“and”,表示两个函数或函数组合之间是并列的关系,即两者的匹配条件都必须满足
- 或操作符:“||”或者“or”,表示两个函数或函数组合之间是或的关系,即两者只要满足一个匹配条件即可
- 非操作符:“!”或者“not”,表示只有函数或函数组合不能匹配的才能满足
4.2 execution函数
execution函数用于通过方法匹配模式匹配方法,其传入参数为方法匹配字符串,如下面的表达式表示匹配到demo.spring.aop.TargetClass.method1(String,String)方法:
execution(* demo.spring.aop.TargetClass.method1(String,String))
方法模式字符串的格式为:[方法可见性] 方法返回值 [方法所在类的全限定名]方法名(参数列表) [方法抛出的异常类型],其中,方括号的内容是可选的。
4.3 @annotation函数
@annotation函数用于匹配包含指定注解的方法,注解由函数的参数指定,如下面的表达式,表示匹配所有标注了DemoAnnotation注解的方法:
@annotation(demo.spring.aop.annotation.DemoAnnotation)
4.4 args函数
args函数用于匹配包含指定参数类型的方法,方法的参数类型由函数的参数指定。如下面的表达式,表示匹配所有有且仅有两个参数,且第一个参数为string类型,第二个参数为demo.spring.aop.target.TargetClass类型的方法:
args(String, demo.spring.aop.target.TargetClass)
方法的参数类型应使用参数的全限定类名,但对于java.lang包中的类,可以使用简写。
4.5 @args函数
@args函数用于匹配参数列表中,指定的参数标注了指定类型的注解的方法。如下面的示例,表示匹配任何有且仅有一个参数,且参数中标注了DemoAnnotation注解的方法:
@args(demo.spring.annotation.DemoAnnotation)
4.6 within函数
within函数表示匹配满足条件的包或类下面所有的方法,如下面两个表达式中,第一个表示匹配demo.spring.target包下所有类的所有方法,而第二个表达式表示匹配demo.spring.target.TargetClass类下面所有的方法:
within(demo.spring.target.*)
within(demo.spring.target.TargetClass)
4.7 @within函数
@within函数匹配包含特定的注解的类及其子类的所有执行方法连接点。如下面的表达式,表示匹配所有标注了DemoAnnotation注解的类及其子类的所有方法:
@within(demo.spring.annotation.DemoAnnotation)
如果类demo.spring.target.TagetClass类标注了DemoAnnotation,则这个类和它的所有子类的所有方法都会被匹配到。
4.8 target函数
target函数匹配指定类及其子类的所有连接点,如下面的代码所示,TargetClass类及其子类的所有连接点都会被匹配:
target(demo.spring.target.TargetClass)
4.9 @target函数
@target函数匹配标注了指定注解的类及其子类的所有执行方法连接点。如下面的代码所示,所有标注了DemoAnnotation注解的类及其子类的连接点都会被匹配:
@target(demo.spring.annotation.DemoAnnotation)
4.10 this函数
this函数大体上与target函数相同,在多数场景下具有相同的语义,但对于一些特定的场景,具有与target不同的语义。
this(demo.spring.target.TargetClass)
上面的表达式中,如果TargetClass实现了一个单一的接口,而Spring AOP框架使用基于JDK的动态代理为TargetClass创建代理实例,由于JDK动态代理是基于接口,因此代理实例并不是TargetClass的实例,而是TargetClass所实现的接口的实例,这种情况下上述的表达式就不能匹配到代理实例的连接点,其他的情况下,this函数的语义与target函数语义是一致的。