Aop切面与自定义注解的日常应用


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 AOPAspectJ 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请求示例

postman请求示例

打印Log

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请求示例

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代码

JavaDemo:aop-demo

参考链接:
1:自定义注解(Annontation)
2:厉害了!老大利用AOP实现自定义注解,半小时完成我三天工作量
3:如何优雅地在 Spring Boot 中使用自定义注解,AOP 切面统一打印出入参日志
4:Spring 自定义注解从入门到精通
5:9000字,通俗易懂的讲解下Java注解
6:JavaGuide/Spring常见问题总结
7:写了个牛逼的日志切面,甩锅更方便了
8:使用 SpringBoot AOP 记录操作日志、异常日志


评论
  目录