一.Spring AOP
1.什么是Spring AOP
AOP(Aspect Oriented Programming):面向切面编程,它是一种思想,它是对某一类事情的集中处理。
2.AOP的作用
想象一个场景,我们在做后台系统时,除了登录和注册等几个功能不需要做用户登录验证之外,其他几乎所有页面调用的前端控制器( Controller)都需要先验证用户登录的状态,那这个时候我们要怎么处 理呢?
我们之前的处理方式是每个 Controller 都要写一遍用户登录验证,然而当你的功能越来越多,那么你要 写的登录验证也越来越多,而这些方法又是相同的,这么多的方法就会代码修改和维护的成本。那有没 有简单的处理方案呢?答案是有的,对于这种功能统一,且使用的地方较多的功能,就可以考虑 AOP 来统一处理了。
- 除了统一的用户登录判断之外,AOP 还可以实现:
- 统一日志记录
- 统一方法执行时间统计
- 统一的返回格式设置
- 统一的异常处理
- 事务的开启和提交等
- AOP是OOP的补充
3.AOP的相关概念
1.切面(Aspect)
面(Aspect)由切点(Pointcut)和通知(Advice)组成,它既包含了横切逻辑的定义,也包
括了连接点的定义。
2.连接点 (Join Point)
应用执行过程中能够插入面的一个点,这个点可以是方法调用时,抛出异常时,甚至修改字段 时。切面代码可以利用这些点插入到应用的正常流程之中,并添加新的行为
3.切点(Pointcut)
Pointcut 是匹配 Join Point 的谓词。 Pointcut 的作用就是提供一组规则(使用 AspectJ pointcut expression language 来描述)来 匹配 Join Point,给满足规则的 Join Point 添加 Advice
4.通知(Advice)
切面也是有目标的 ——它必须完成的工作。在 AOP 术语中,切面的工作被称之为通知。
通知:定义了切面是什么,何时使用,其描述了面要完成的工作,还解决何时执行这个工作的
问题。
Spring 切面类中,可以在方法上使用以下注解,会设置方法为通知方法,在满足条件后会通知本
方法进行调用:
- 前置通知使用 @Before:通知方法会在目标方法调用之前执行。
- 后置通知使用 @After:通知方法会在目标方法返回或者抛出异常后调用。
- 返回之后通知使用 @AfterReturning:通知方法会在目标方法返回后调用。
- 抛异常后通知使用 @AfterThrowing:通知方法会在目标方法抛出异常后调用。
- 环绕通知使用 @Around:通知包裹了被通知的方法,在被通知的方法通知之前和调用之后执
- 行自定义的行为。
4.Spring AOP的实现
1.添加 AOP 框架支持
在 pom.xml 中添加如下配置:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Springboot test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring AOP 框架-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
2.定义切面和切点
@Component
@Slf4j
@Aspect
public class LoginAspect {
@Pointcut("execution(* com.javastudy.springaopdemo5.controller.LoginController.*(..))")
public void pointcut() {
}
}
@Aspect类注解表示LoginAspect是一个切面类,@Pointcut表示定义一个切点,其中的内容表示连接点的规则,也就是包括哪些类或者方法属于这个切点,连接点.
其中 pointcut 方法为空方法,它不需要有方法体,此方法名就是起到⼀个“标识”的作用,标识下面的
通知方法具体指的是哪个切点(因为切点可能有很多个)
AspectJ 支持三种通配符
* :匹配任意字符,只匹配⼀个元素(包,类,或方法,方法参数)
.. :匹配任意字符,可以匹配多个元素 ,在表示类时,必须和 * 联合使用。
+ :表示按照类型匹配指定类的所有类,必须跟在类名后面,如 com.cad.Car+ ,表示继承该类的
所有子类包括本身
切点表达式由切点函数组成,其中 execution() 是最常用的切点函数,用来匹配方法,语法为:
execution(<修饰符><返回类型><包.类.方法(参数)><异常>)
修饰符和异常可以省略,具体含义如下:
上面我们定义的含义为,匹配com.javastudy.springaopdemo5.controller.LoginController类下所有的方法.
3.定义相关通知
先来定义controller层的内容
@RestController
@Slf4j
@RequestMapping("/user")
public class LoginController {
@RequestMapping("/login")
public String login() {
log.info("login...");
return "login...";
}
@RequestMapping("/register")
public String register() {
log.info("register...");
return "register...";
}
@RequestMapping("/get")
public String get() {
log.info("get...");
return "get...";
}
}
1.前置通知 @Before
通知方法会在目标方法调用之前执行。
注意:@Before里面的内容表示切点,即在哪些接口中执行这些通知.
@Component
@Slf4j
@Aspect
public class LoginAspect {
@Pointcut("execution(* com.javastudy.springaopdemo5.controller.LoginController.*(..))")
public void pointcut() {
}
//前置通知
@Before("pointcut()")
public void doBefore() {
log.info("do before....");
}
}
当我们访问任意一个页面的时候,控制台打印如下日志
可以看到都会先执行doBefore通知.
2.后置通知 @After
通知方法会在目标方法返回或者抛出异常后调用。
//后置通知
@After("pointcut()")
public void doAfter() {
log.info("do after...");
}
模拟异常情况,可以在LoginController中某一个方法加10/0,观察
@RequestMapping("/login")
public String login() {
log.info("login...");
int i=10/0;
return "login...";
}
可以观察到在方法异常之后, @After通知仍然会执行.
3.返回之后通知 @AfterReturning
通知方法会在目标方法返回后调用。
// return 之前通知
@AfterReturning("pointcut()")
public void doAfterReturning() {
log.info("do after returning...");
}
当我们访问login接口的时候(10/0,有异常),观察是否有输出
此时是没有输出的.
访问其它接口,没有异常的接口
此时@AfterReturning通知正常执行
4.抛异常后通知 @AfterThrowing
通知方法会在目标方法抛出异常后调用。
//抛出异常之前通知
@AfterThrowing("pointcut()")
public void doAfterThrowing() {
log.info("do after throwing");
}
执行有异常的接口,有日志的打印
执行没有异常的接口,没有日志的打印
5.环绕通知 @Around
通知包裹了被通知的方法,在被通知的方法通知之前和调用之后执行自定义的行为。
@Around("pointcut()")
public Object doAround(ProceedingJoinPoint joinPoint) {
Object oj = null;
log.info("环绕通知执行之前...");
log.info(joinPoint.getSignature().toLongString());
try {
oj = joinPoint.proceed();//调用目标方法
} catch (Throwable e) {
throw new RuntimeException(e);
}
log.info("环绕通知执行之后....");
return oj;
}
执行没有异常的接口:
执行有异常的接口:
接下来我们看看ProceedingJoinPoint 常用方法
toString | 连接点所在位置的相关信息 |
toShortString | 连接点所在位置的简短相关信息 |
toLongString | 连接点所在位置的全部相关信息 |
getThis | 返回AOP代理对象,也就是com.sun.proxy.$Proxy18 |
getTarget | 返回目标对象(定义方法的接口或类) |
getArgs() | 返回被通知方法参数列表 |
getSignature | 返回当前连接点签名,其getName()方法返回方法的FQN |
执行所有的通知,观察环绕通知和前置通知和后置通知的先后顺序
可以观察到,环绕通知先于before,后于after.
如果一个切点只含有一个通知,那么我们可以将切点的规则放在通知上
@Component
@Slf4j
@Aspect
public class LoginAspect {
//前置通知
@Before("execution(* com.javastudy.springaopdemo5.controller.LoginController.*(..))")
public void doBefore() {
log.info("do before....");
}
}
4.Spring AOP 实现原理
Spring AOP 是构建在动态代理基础上,因此 Spring 对 AOP 的支持局限于方法级别的拦截。
Spring AOP 支持 JDK Proxy 和 CGLIB 方式实现动态代理。默认情况下,实现了接口的类,使
用 AOP 会基于 JDK 生成代理类,没有实现接口的类,会基于 CGLIB 生成代理类。
代理模式:
1.静态代理
1.定义接口
public interface PayService {
void pay();
}
2.实现接口
public class AliPayService implements PayService{
@Override
public void pay() {
System.out.println("ali pay...");
}
}
3.创建代理类, 并同样实现支付接口
public class StaticProxy implements PayService {
private final PayService payService;
public StaticProxy(PayService payService) {
this.payService = payService;
}
@Override
public void pay() {
System.out.println("before...");
payService.pay();
System.out.println("after...");
}
}
4.实际使用
public static void main(String[] args) {
PayService service = new AliPayService();
PayService proxy = new StaticProxy(service);
proxy.pay();
}
静态代理有个很大的缺点,就是当有很多不同的接口的时候,我们需要定义很多个代理类实现不同的接口,当我们代理实现的功能相同的时候,但是有多个接口,此时完成这么多代理类很麻烦,此时需要我们的动态代理.
2.动态代理
1.JDK动态代理
从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中 的。
就 Java 来说,动态代理的实现方式有很多种,比如 JDK 动态代理、CGLIB 动态 代理等等。
定义JDK动态代理类
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
/**
* @author Chooker
* @create 2023-07-27 22:36
*/
public class JDKInvocationHandler implements InvocationHandler {
//⽬标对象即就是被代理对象
private Object target;
public JDKInvocationHandler(Object target) {
this.target = target;
}
//proxy代理对象
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//1.安全检查
System.out.println("安全检查");
//2.记录⽇志
System.out.println("记录⽇志");
//3.时间统计开始
System.out.println("记录开始时间");
//通过反射调⽤被代理类的⽅法
Object retVal = method.invoke(target, args);
//4.时间统计结束
System.out.println("记录结束时间");
return retVal;
}
}
创建⼀个代理对象并使用
public static void main(String[] args) {
PayService target = new AliPayService();
//创建⼀个代理类:通过被代理类、被代理实现的接⼝、⽅法调⽤处理器来创建
PayService proxy = (PayService) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
new Class[]{PayService.class},
new JDKInvocationHandler(target)
);
proxy.pay();
}
缺点:JDK的动态代理必须有接口
2.CGLIB动态代理
CGLIB 动态代理类使用步骤
1. 定义一个类;
2. 自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强
被代理类的方法,和 JDK 动态代理中的 invoke 方法类似;
3. 通过 Enhancer 类的 create()创建代理类
添加依赖(如果创建的是一个Spring项目,不需要引入,因为Spring底层已经引入了cglib框架)
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.3.0</version>
</dependency>
自定义 MethodInterceptor(方法拦截器)
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
/**
* @author Chooker
* @create 2023-07-27 22:48
*/
public class CGLIBInterceptor implements MethodInterceptor {
//被代理对象
private Object target;
public CGLIBInterceptor(Object target) {
this.target = target;
}
@Override
public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
//1.安全检查
System.out.println("安全检查");
//2.记录⽇志
System.out.println("记录⽇志");
//3.时间统计开始
System.out.println("记录开始时间");
//通过cglib的代理⽅法调⽤
Object retVal = methodProxy.invoke(target, args);
//4.时间统计结束
System.out.println("记录结束时间");
return retVal;
}
}
1. obj : 被代理的对象(需要增强的对象)
2. method : 被拦截的方法(需要增强的方法)
3. args : 方法入参
4. proxy : 用于调用原始方法
创建代理类, 并使用
public static void main(String[] args) {
PayService target = new AliPayService();
PayService proxy = (PayService) Enhancer.create(target.getClass(), new CGLIBInterceptor(target));
proxy.pay();
}
JDK 动态代理和 CGLIB 动态代理对比
1. JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代 理未实现任何接口的类。
2. CGLIB 动态代理是通过生成⼀个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final
性能: 大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更 加明显。
Spring代理选择
1. proxyTargetClass 为false, 目标实现了接口, 用jdk代理
2. proxyTargetClass 为false, 目标未实现接口, 用cglib代理
3. proxyTargetClass 为true, 用cglib代理
织入(Weaving):代理的生成时机
织入是把切面应用到目标对象并创建新的代理对象的过程,切面在指定的连接点被织入到目标对
象中。
在目标对象的生命周期里有多个点可以进行织入:
- 编译期:切面在目标类编译时被织入。这种方式需要特殊的编译器。AspectJ的织入编译器就是以这种方式织入切面的。
- 类加载期:切面在目标类加载到JVM时被织入。这种方式需要特殊的类加载器(ClassLoader),它可以在目标类被引入应用之前增强该目标类的字节码。AspectJ5的加载时织入(load-time weaving. LTW)就支持以这种方式织入切面。
- 运行期:切面在应用运行的某⼀时刻被织入。⼀般情况下,在织入切面时,AOP容器会为目标对象动态创建⼀个代理对象。SpringAOP就是以这种方式织入切面的。
上面我们学习的是Spring AOP的原理,是接下来我们学习内容的底层,SpringBoot一些常见的功能进行了封装,底层使用AOP实现的
二.SpringBoot 统一功能处理
需要实现用户的登录权限的校验功能,在Servlet阶段,我们可以通过在Session中保存用户的信息,之后每个页面先通过session中判断是否存在用户的信息,如果存在说明用户已经登录过了,没有就跳转到登录的页面.
1.Spring AOP 用户统一登录验证的问题
我们第一时间想到的就是通过环绕通知来解决这个问题,可以对除了登录和注册的页面采用环绕通知,用来判断用户是否登录过了.但是会出现以下两个问题
1.. 没办法获取到 HttpSession 对象。
2. 我们要对一部分方法进行拦截,而另一部分方法不拦截,如注册方法和登录方法是不拦截的,这样 的话排除方法的规则很难定义,甚至没办法定义。
那么该如何解决呢?
2.Spring 拦截器
对于以上问题 Spring 中提供了具体的实现拦截器:HandlerInterceptor,拦截器的实现分为以下两个步骤:
1. 创建自定义拦截器,实现 HandlerInterceptor 接口的 preHandle(执行具体方法之前的预处理)方法。
2. 将自定义拦截器加入 WebMvcConfigurer 的 addInterceptors 方法中
1.自定义拦截器
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("username") != null) {
//通过
return true;
}
//没有权限访问
response.setStatus(401);
return false;
}
}
2.将自定义拦截器加入到系统配置
@Configuration
public class AppConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor).
//表示拦截所有的路径
addPathPatterns("/**").
//不拦截login接口
excludePathPatterns("/login").
//不拦截register接口
excludePathPatterns("/register");
}
}
其中:
addPathPatterns:表示需要拦截的 URL,“**”表示拦截任意方法(也就是所有方法)。
excludePathPatterns:表示需要排除的 URL。
说明:以上拦截规则可以拦截此项目中的使用 URL,包括静态文件(图片文件、JS 和 CSS 等文件)。
排除所有的静态资源
// 拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**") // 拦截所有接⼝
.excludePathPatterns("/**/*.js")
.excludePathPatterns("/**/*.css")
.excludePathPatterns("/**/*.jpg")
.excludePathPatterns("/login.html")
.excludePathPatterns("/**/login"); // 排除接⼝
}
拓展以下,可以在里面添加统一前缀的添加
@Configuration
public class AppConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")// 拦截所有url
.excludePathPatterns("/api/user/login")
.excludePathPatterns("/api/user/reg");
}
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("api", c -> true);
}
}
3.controller接口模仿登录
@RestController
@Slf4j
@RequestMapping("/user")
public class LoginController {
@RequestMapping("/login")
public boolean login(HttpServletRequest request, String username, String password) {
log.info("login...");
if (!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
return false;
}
//此时表示账号密码正确
if ("admin".equals(username) && "123456".equals(password)) {
HttpSession session = request.getSession(true);
session.setAttribute("username", username);
return true;
}
return false;
}
@RequestMapping("/register")
public String register() {
log.info("register...");
return "register...";
}
@RequestMapping("/get")
public String get() {
log.info("get...");
return "get...";
}
}
当我们直接访问get接口的时候:显示的是401,表示没有权限
正常访问login和register接口都是可以实现的
此时我们使用正确的账号密码登录:可以看到此时已经正确登录了
此时我们再次访问get接口:可以看到此时正确访问
3.拦截器的实现原理
正常情况下的调用顺序:
然而有了拦截器之后,会在调用 Controller 之前进行相应的业务处理,执行的流程如下图所示:
拦截器是基于AOP的,Spring是基于Servlet的
三.统一异常处理
@ControllerAdvice
@ResponseBody
public class ErrorHandler {
@ExceptionHandler(Exception.class)
public Object error(Exception e) {
HashMap<String, Object> map = new HashMap<>();
map.put("success", 0);
map.put("status", -1);
map.put("msg", e.getMessage());
return map;
}
@ExceptionHandler(NullPointerException.class)
public Object error2(NullPointerException e) {
HashMap<String, Object> result = new HashMap<>();
result.put("success", 0);
result.put("status", -2);
result.put("message", "空指针异常:" + e.getMessage());
return result;
}
@ExceptionHandler(ArithmeticException.class)
public Object error2(ArithmeticException e) {
HashMap<String, Object> result = new HashMap<>();
result.put("success", 0);
result.put("status", -3);
result.put("message", "算数异常:" + e.getMessage());
return result;
}
}
controller
@RestController
@Slf4j
@RequestMapping("/error")
public class ErrorController {
@RequestMapping("/test1")
public boolean test1() {
int i = 10 / 0;
return true;
}
@RequestMapping("/test2")
public boolean test2() {
String a = null;
a.length();
return true;
}
@RequestMapping("/test3")
public String test3() {
throw new RuntimeException("test3手动创建异常");
}
}
访问test1
访问test2
访问test3
可以观察到当错误异常为子类的时候,匹配顺序为当前类及其子类向上依次匹配
三.统一数据返回格式
1.为什么需要统一返回格式
统一数据返回格式的优点有很多,比如以下几个:
- 方便前端程序员更好的接收和解析后端数据接口返回的数据。
- 降低前端程序员和后端程序员的沟通成本,按照某个格式实现就行了,因为所有接口都是这样返回的。
- 有利于项目统一数据的维护和修改。
- 有利于后端技术部门的统一规范的标准制定,不会出现稀奇古怪的返回内容
2.统一数据返回格式的实现
@ControllerAdvice
public class ResponseHandler implements ResponseBodyAdvice {
/**
* 内容是否需要重写(通过此⽅法可以选择性部分控制器和⽅法进⾏重写)
* 返回 true 表示重写
*/
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;
}
/**
* ⽅法返回之前调⽤此⽅法
*/
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// 构造统⼀返回对象
HashMap<String, Object> result = new HashMap<>();
result.put("state", 1);
result.put("msg", "");
result.put("data", body);
if(body instanceof String){
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.writeValueAsString(result);
}
return result;
}
}
假如没有if((body instanceof String)这一段代码,会发生如下的错误
controller:
@RestController
@Slf4j
@RequestMapping("/user")
public class LoginController {
@RequestMapping("/login")
public boolean login(HttpServletRequest request, String username, String password) {
log.info("login...");
if (!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
return false;
}
//此时表示账号密码正确
if ("admin".equals(username) && "123456".equals(password)) {
HttpSession session = request.getSession(true);
session.setAttribute("username", username);
return true;
}
return false;
}
@RequestMapping("/register")
public String register() {
log.info("register...");
return "register...";
}
@RequestMapping("/get")
public String get() {
log.info("get...");
return "get...";
}
}