Aop切面与自定义注解的日常应用
1.引言
众所周知,AOP真的是面试里老生常谈的技术点..想当年毕业找工作时,10场面试里7.8场都问AOP。大学毕业前,你肯定怎么都想不到,这辈子会被三个字母搞的晕头转向,脑瓜子嗡嗡的。而如今混了几年的我,决定手撕了他..的表皮,拿来做灵活的日常开发小助手。
2.AOP
我们先来聊聊AOP这个知识点。AOP,面向切面编程,我们可以把例如日志管理、事务处理、权限控制等这样的公用模块封装起来,通过切面的方式嵌入到业务代码中,降低系统耦合度,也提升了系统的可扩展性和可维护性。
日常使用中,大家可以想到
@Transaction
事务注解。
框架源码里,也有很多例子。例如Alibaba Sentinel有代码形式的限流和注解形式的限流,其中注解形式的限流就是用到AOP切面的思想,Around环绕来实现限流
AOP是基于动态代理的:
JDK Proxy:如果要代理的对象,实现了某个接口,那么Spring AOP会使用 JDK Proxy,去创建代理对象;
Cglib:对于没有实现接口的对象,就无法使用JDK Proxy 去进行代理了,这时候Spring AOP会使用 Cglib,Spring AOP会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:
【面试题】:Spring AOP的实现原理是什么?
你可以首先用一句话回答,“Spring AOP是通过JDK的动态代理和CGLIB框架实现的”;然后进一步展开,讲述JDK动态代理和CGLIB的实现原理,“JDK动态代理的实现原理是在运行期目标类加载后,为接口动态生成代理类,将切面植入到代理类中;CGLIB的实现原理是在运行期动态生成一个要代理类的子类,将切面逻辑加入到子类中,子类重写要代理的类的所有不是final的方法。”
使用AOP的选择有两种:Spring AOP 和 AspectJ AOP,Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。
一般情况下,我们选择AspectJ AOP即可,他在切面多的情况下性能更高。
3.自定义注解
我们从业务角度想想,既然不是每个接口都打上log,而是通过AOP切面的方式来记录,那么肯定有一个通用模版或者说通用标识对吧?比如前面提到的@Transaction
事务注解,我们要实现自己的业务,当然也搞个自己喜欢的注解,这里就用@RequestLog
表示系统需要对请求打Log。
命名完了,我们是不是需要打Log这个动作做一些限制呢?比如我只让他在方法级别上打Log,这里就用到了元注解(注解的注解)@Target(ElementType.METHOD)
。
限制完了,我们是不是可以打Log这个动作做一些备注或者记录呢?比如打Log的方法作用描述,让日志更友好,这里就用到了我们平时写实体类中的属性一样的东西(只不过换了种表现形式,这里用的是方法)
// 描述 默认空字符串
String desc() default "";
备注记录完了,那么如何把日志打上呢?我们就需要在这个方法执行的前后做点手脚,比如获取到被我们注解标注的方法啦,请求的参数、花费的时间啥的,拿到这些信息后打印记录下来。这里应运而生的就是切面类 RequestLogAop 了。
自定义注解的小(冷)知识挺多也挺细节的,我这里就简单介绍一下我们即将用到的。实战搞定后,大家可以查看底部的参考链接来了解自定义注解
4.实战
4.1 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>2.3.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.4.3</version>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.4</version>
</dependency>
4.2 入参日志Log
1.自定义注解@RequestLog
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface RequestLog {
/**
* 描述
* @return
*/
String desc() default "";
}
1:
@Retention(RetentionPolicy.RUNTIME)
:运行时可以使用该注解标识目标(这里具体指方法)
2:@Target(ElementType.METHOD)
:此注解标识在方法上
3:@Documented
:用于制作文档
4:@interface
:用来定义注解,类似于interface用于定义接口
5:desc():可以理解为该注解的属性
2.日志封装类 RequestLogInfo
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RequestLogInfo {
/**
* 请求链接
*/
private String requestUri;
/**
* 接口描述
*/
private String apiDesc;
/**
* 请求类型
*/
private String requestType;
/**
* HTTP请求方法
*/
private String httpMethod;
/**
* Class路径方法
*/
private String classMethod;
/**
* 请求IP
*/
private String requestIp;
/**
* 请求参数
*/
private String requestParams;
/**
* 花费时间 单位毫秒(ms)
*/
private Long costTimeMillis;
/**
* 接口返回结果
*/
private String result;
}
用于记录信息用的封装类
3.日志切面类 RequestLogAop
@Component
@Aspect // 标识这是一个切面
@Slf4j
public class RequestLogAop {
/**
* 被 @requestLog 所注解的切点
* @param requestLog
*/
@Pointcut("@annotation(requestLog)")
public void requestLogPointcut(RequestLog requestLog){}
/**
* 环绕增强
* @param pjp
* @param requestLog
*/
@Around(value = "requestLogPointcut(requestLog)", argNames = "pjp,requestLog")
public Object around(ProceedingJoinPoint pjp, RequestLog requestLog) throws Throwable {
// 请求信息
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
// 使用包装类目的为了减少多请求情况下 日志串行的问题
RequestLogInfo info = RequestLogInfo.builder()
.requestUri(request.getRequestURI())
.apiDesc(requestLog.desc())
.httpMethod(request.getMethod())
.classMethod(pjp.getSignature().getDeclaringTypeName() + "." +pjp.getSignature().getName())
.requestIp(request.getRemoteHost())
.requestParams(JSONUtil.toJsonStr(pjp.getArgs()))
.build();
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// log.info("【请求链接】:{}",request.getRequestURI());
// log.info("【接口描述】:{}",requestLog.desc());
// log.info("【请求类型】:{}",request.getMethod());
// log.info("【请求方法】:{}.{}",pjp.getSignature().getDeclaringTypeName(),pjp.getSignature().getName());
// log.info("【请求IP】:{},{}:{}",request.getRemoteAddr(),request.getRemoteHost(),request.getRemotePort());
// log.info("【请求参数】:{}", JSONUtil.toJsonStr(pjp.getArgs()));
// 执行原方法逻辑
Object result = pjp.proceed();
stopWatch.stop();
info.setCostTimeMillis(stopWatch.getTotalTimeMillis());
info.setResult(JSONUtil.toJsonStr(result));
log.info("【requestLog】:{}",JSONUtil.toJsonStr(info));
// log.info("【接口花费时间统计】:{} 秒",stopWatch.getTotalTimeSeconds());
// log.info("【接口花费时间调度】:{}",stopWatch.prettyPrint());
// log.info("【请求返回结果】:{}",JSONUtil.toJsonStr(result));
return result;
}
}
@Aspect
:声明该类为一个注解类@Pointcut
:定义一个切点,后面跟随一个表达式,表达式可以定义为切某个注解,也可以切某个 package 下的方法@Before
: 在切点之前,织入相关代码@After
: 在切点之后,织入相关代码@AfterReturning
: 在切点返回内容后,织入相关代码,一般用于对返回值做些加工处理的场景@AfterThrowing
: 用来处理当织入的代码抛出异常后的逻辑处理@Around
: 环绕,可以在切入点前后织入代码,并且可以自由的控制何时执行切点
4.测试
@RestController
@Slf4j
public class TestController {
@RequestLog
@PostMapping("/requestLog/test")
public String requestLog(@RequestParam String param){
return "success";
}
}
测试Controller类方法
postman请求示例
4.3 分布式锁(本文基于Redisson)
关于Redisson分布式锁相关,可以查看笔者的另一篇文章
浅谈Redis客户端Redisson | 寒暄
1.相关配置类
// 1.application.yml配置
# 应用端口号
server:
port: 10086
# redis配置
redisson:
address: redis://127.0.0.1:6379
database: 0
# password: foobared
// 2.redisson配置参数(yml)
@Data
@ConfigurationProperties(prefix = "redisson")
public class RedissonProperties {
private String address;
private Integer database;
private String password;
}
// 3.redisson配置类
@Configuration
@EnableConfigurationProperties({RedissonProperties.class})
public class RedissonConfig {
@Primary
@Bean(name="defaultRedisClient",destroyMethod = "shutdown")
public RedissonClient redissonClient(RedissonProperties redissonProperties){
Config config = new Config();
config.useSingleServer()
.setAddress(redissonProperties.getAddress())
.setDatabase(redissonProperties.getDatabase())
// 连接最小初始化的个数
.setConnectionMinimumIdleSize(10)
.setPassword(redissonProperties.getPassword());
return Redisson.create(config);
}
}
2.自定义注解@DistributedLock
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface DistributedLock {
/**
* 锁时间 默认2000毫秒
* @return
*/
long lockTime() default 2000L;
}
3.切面类 DistributedLockAop
@Component
@Aspect
@Slf4j
public class DistributedLockAop {
/**
* redisson客户端
*/
@Autowired
private RedissonClient redissonClient;
@Pointcut("@annotation(distributedLock)")
public void distributeLockPointcut(DistributedLock distributedLock){}
/**
* 环绕增强
* @param pjp
* @param distributedLock
*/
@Around(value = "distributeLockPointcut(distributedLock)", argNames = "pjp,distributedLock")
public Object around(ProceedingJoinPoint pjp, DistributedLock distributedLock) throws Throwable {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
// 请求路径
String servletPath = request.getServletPath();
// 用户ID
Long userId = MapUtil.getLong(ApiReqContextHolder.getContext().getSession(),"userId");
String lockKey = userId + " | " + servletPath;
RLock lock = redissonClient.getLock(lockKey);
long waitTime = distributedLock.waitTime();
long leaseTime = distributedLock.leaseTime();
if(lock.isLocked()){
log.warn("lockKey = {},重复提交!",lockKey);
throw new ServiceException(201,"访问太多次啦");
}
try{
// 尝试加锁,最多等待lockTime毫秒,上锁以后leaseTime毫秒自动解锁
if(!lock.tryLock(waitTime,leaseTime, TimeUnit.MILLISECONDS)){
log.warn("lockKey = {},正在处理中!",lockKey);
throw new ServiceException(201,"正在处理中,请稍后再试!");
}
// 真正执行的方法
return pjp.proceed();
}catch (InterruptedException e) {
log.warn("lockKey = {},重复提交!",lockKey);
throw new ServiceException(201, "请勿重复提交!");
}finally {
// 解锁(当前线程持有该锁才可进行解锁操作)
if(lock.isLocked() && lock.isHeldByCurrentThread()){
log.info("【分布式锁-解锁】lockKey:{}",lockKey);
lock.unlock();
}
}
}
}
解锁时的注意事项:
lock.isHeldByCurrentThread()表示锁是否被当前线程所持有
如果少了lock.isHeldByCurrentThread()这一判断,在有异步操作时会出现下面这样的异常,提示当前线程并不持有我们需要解的锁,无法尝试去解锁
4.测试
测试Controller类方法
@RestController
@Slf4j
public class TestController {
@DistributedLock
@PostMapping("/distributedLock/test")
public String distributedLock(@RequestParam("sleepTime") Integer sleepTime,
@RequestParam("isError") Boolean isError){
if(isError){
throw new ServiceException(500,"出现异常");
}
ThreadUtil.sleep(sleepTime);
return "success";
}
}
postman请求示例
4.4 异常日志
1.自定义注解@ExceptionLog
// 运行时使用该注解
@Retention(RetentionPolicy.RUNTIME)
// 作用于方法
@Target(ElementType.METHOD)
@Documented
public @interface ExceptionLog {
}
2.mysql异常日志记录表
3.表对应异常日志类 ExceptionLogInfo
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "exception_log")
public class ExceptionLogInfo {
@Id
private Integer id;
/**
* api请求路径
*/
@Column(name = "api_path")
private String apiPath;
/**
* api方法
*/
@Column(name = "api_method")
private String apiMethod;
/**
* 请求路径
*/
@Column(name = "uri_path")
private String uriPath;
/**
* 请求参数
*/
@Column(name = "request_param")
private String requestParam;
/**
* 异常堆栈信息
*/
@Column(name = "ex_message")
private String exMessage;
/**
* 创建时间
*/
@Column(name = "create_time")
private Date createTime;
}
4.日志切面类 ExceptionLogAop
@Aspect
@Component
@Slf4j
public class ExceptionLogAop {
private final ExceptionLogMapper exceptionLogMapper;
@Autowired
public ExceptionLogAop(ExceptionLogMapper exceptionLogMapper) {
this.exceptionLogMapper = exceptionLogMapper;
}
/**
* 被 @exceptionLog 所注解的切点
* @param exceptionLog
*/
@Pointcut("@annotation(exceptionLog)")
public void exceptionLogPointcut(ExceptionLog exceptionLog){}
/**
* 异常记录处理
* @param joinPoint
* @param exceptionLog
* @param e
*/
@AfterThrowing(value = "exceptionLogPointcut(exceptionLog)", throwing = "e", argNames = "joinPoint,exceptionLog,e")
public void saveExceptionLog(JoinPoint joinPoint,ExceptionLog exceptionLog, Throwable e) {
// 请求信息
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
Signature signature = joinPoint.getSignature();
// 持久化异常相关信息到数据库
ExceptionLogInfo log = ExceptionLogInfo.builder()
.apiPath(request.getServletPath())
.apiMethod(request.getMethod())
.uriPath(signature.getDeclaringTypeName() + "." + signature.getName())
.requestParam(convertMap(request.getParameterMap()))
.exMessage(ExceptionUtil.stacktraceToOneLineString(e))
.createTime(DateUtil.date())
.build();
exceptionLogMapper.insertSelective(log);
}
/**
* 请求参数格式转换
* Map<String,String[]> -> Map<String,String>的json字符串
* @param paramMap
* @return
*/
private String convertMap(Map<String,String[]> paramMap){
Set<Map.Entry<String, String[]>> entries = paramMap.entrySet();
if(!entries.isEmpty()){
Map<String, String> map = new HashMap<>(entries.size());
for (Map.Entry<String, String[]> entry : entries) {
map.put(entry.getKey(),entry.getValue()[0]);
}
return JSONUtil.toJsonStr(map);
}
return "";
}
}
记录异常日志信息主要使用到的是
@AfterThrowing
注解和切面。再结合切点和request/反射等方式获取到请求和异常等信息,汇总记录持久化下来
5.测试
测试Controller类方法
postman请求示例
数据库记录
4.5 demo代码
参考链接:
1:自定义注解(Annontation)
2:厉害了!老大利用AOP实现自定义注解,半小时完成我三天工作量
3:如何优雅地在 Spring Boot 中使用自定义注解,AOP 切面统一打印出入参日志
4:Spring 自定义注解从入门到精通
5:9000字,通俗易懂的讲解下Java注解
6:JavaGuide/Spring常见问题总结
7:写了个牛逼的日志切面,甩锅更方便了
8:使用 SpringBoot AOP 记录操作日志、异常日志