基于Spring AOP的日志功能实现

前言

前一段时间学习了Spring,明确Spring的两大特征:IoC控制反转和AOP面向切面编程。后续遇到了系统日志功能,实现的时候使用到了AOP,在此进行总结。

IoC:主要是将程序中的对象通过创建bean对象的方式将其加入到Spring容器中,通过依赖注入的方式调用容器中的bean对象,从而降低程序间的依赖性(传统是通过new 方式获取类对象)

AOP:面向切面编程,抽取出程序中重复度较高的代码,然后项目中哪里需要使用,就通过反向代理的方式调用这部分重复度高的代码,实现原功能的代码增强。

参考链接

系统操作日志的实现:

https://blog.csdn.net/t_jindao/article/details/85259145

https://blog.csdn.net/Danny1992/article/details/103684567

AOP知识点:

https://baike.baidu.com/item/AOP/1332219?fr=aladdin

https://www.jianshu.com/p/5b9a0d77f95f

AOP介绍

详细的基于XML实现和基于注解实现可查看此篇博客知识点中的第三点AOP介绍:https://blog.csdn.net/qq_38586378/article/details/107775845

AOP的概念

Aspect Oriented Programming,面向切面编程。是OOP的延续,利用AOP可以对业务逻辑的各个部分的重复代码抽取出来,在需要执行的时候通过动态代理的技术,在不修改源码的基础上,对已有的方法进行增强。

AOP的优势在于减少重复代码;提高开发效率;便于工程项目维护

AOP的相关术语

1. Target目标类

指代理的目标对象即被代理对象

2. Proxy代理

一个类被AOP织入增强后,就产生一个结果代理类(代理对象)

3. Jointpoint连接点

指被拦截到的点,具体来讲就是项目中的方法

4. Adive增强/通知

指拦截到连接点后需要做的事情,一般会进行增强的连接点为称之为切入点。分为在切入点执行前的通知、在切入点执行正常的通知、执行异常的通知、执行完毕的通知。

4.1 前置通知

切入点方法执行之前的操作。xml中为aop:before,基于注解中是@Before()

4.2 后置通知

切入点方法执行之后的操作。xml中为aop:returning,基于注解中是@AfterReturning()

4.3 异常通知

类似catch操作。xml中为aop:throwing,基于注解中是@AfterThrowing()

4.4 最终通知

类似finally操作。xml中为aop:after,基于注解中是@After()

4.5 环绕通知

xml中是aop:around,基于注解中是@Around。

由于spring框架对于前置-后置/异常-最终顺序没有规定,所以可能处理的时候不会符合用户的执行顺序需求,可使用环绕通知,手动插入需要在切入点之前/之后执行的操作,保证切面增强处理逻辑的顺序正确性

5. Weaving织入

指把增强应用到目标对象来创建新的代理对象的过程(通过aop将通知织入到切入点中,实现代码增强)

6. Introduction引入

一种特殊的通知在不修改类代码的前提下,Introduction可在运行期为类动态地添加一些方法或者Field变量

7. Aspect切面

切入点和通知/引介的结合,一般会在切面中配置落实到增强某个切入点的通知

AOP基于XML的环绕通知实现

1. pom.xml导入AOP依赖

        
        
            org.aspectj
            aspectjweaver
            1.8.7
        

2. 编写Spring配置文件bean.xml




    
    

    

    
    

    
    
        
        
        
        
            

            
            
        
    
package com.practice.utils;

/**
 * @ClassName Logger
 * @Author wx
 * @Date 2020/7/22 0022 20:53
 * @Version 1.0
 * @Attention Copyright (C), 2004-2020, BDILab, XiDian University
 **/

import org.aspectj.lang.ProceedingJoinPoint;

/**
 * 用户记录日志的工具类,里面提供公共的代码
 */
public class Logger {

    /**
     * 环绕通知
     * 问题:
     *      当配置环绕通知之后,切入点方法没有执行,而通知方法被执行了
     * 分析:
     *      通过对比动态代理中的环绕通知代码,发现动态代理中的环绕通知有明确的切入点方法调用,而现在的代码中没有
     * 解决:
     *      Spring框架提供了一个借口,ProceedingJoinPoint。该接口有一个方法proceed(),此方法相当于明确调用切入点方法,
     *      该接口可作为环绕通知的方法参数,在程序执行时spring框架会提供该接口的实现类供我们使用
     *
     * spring中的环绕通知:
     *  是spring框架为我们提供的一种可以在代码中手动控制增强方法何时执行的方式
     */
    public Object aroundPrintLog(ProceedingJoinPoint joinPoint){
        Object returnValue = null;
        try{
            Object[] args = joinPoint.getArgs();
            System.out.println("beforePrintLog...");
            returnValue = joinPoint.proceed(args); //明确调用业务层方法(切入点方法)
            System.out.println("afterReturningPrintLog...");
            return returnValue;
        }catch (Throwable t){
            System.out.println("afterThrowingPrintLog...");
            throw new RuntimeException(t);
        }finally {
            System.out.println("afterPrintLog...");
        }
    }
}

AOP基于注解的环绕通知实现

1. pom.xml导入依赖

        
        
            org.aspectj
            aspectjweaver
            1.8.7
        

2. 在spring配置文件bean.xml中开启注解扫描




    
    

    
    

3. 增强类中添加注解,并编写切入点和通知方法

package com.practice.utils;

/**
 * @ClassName Logger
 * @Author wx
 * @Date 2020/7/22 0022 20:53
 * @Version 1.0
 * @Attention Copyright (C), 2004-2020, BDILab, XiDian University
 **/

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

/**
 * 用户记录日志的工具类,里面提供公共的代码
 */
@Component("logger")
@Aspect //表示当前类是一个切面类
public class Logger {

    @Pointcut("execution(* com.practice.service.impl.*.*(..))")
    private void pt1(){}

    @Around("pt1()")
    public Object aroundPrintLog(ProceedingJoinPoint joinPoint){
        Object returnValue = null;
        try{
            Object[] args = joinPoint.getArgs();
            System.out.println("beforePrintLog...");
            returnValue = joinPoint.proceed(args); //明确调用业务层方法(切入点方法)
            System.out.println("afterReturningPrintLog...");
            return returnValue;
        }catch (Throwable t){
            System.out.println("afterThrowingPrintLog...");
            throw new RuntimeException(t);
        }finally {
            System.out.println("afterPrintLog...");
        }
    }
}

4. 如果需要使用全注解的话,可以在项目启动类上加@EnableAspectJAutoProxy开启注解AOP扫描(加@ComponentScan开启bean扫描)

功能实现过程

需求

一个JavaWeb项目中,涉及例如用户管理、系统管理等模块,现在需要记录每次用户操作,并可查询用户的操作日志。

实现逻辑

其实想想这个功能不难,无非就是在每次操作的时候,生成一条操作日志插入到数据表中,然后提供查询日志的接口即可。但是如果单纯把生成日志对象+往日志表中查数据的代码放在每个方法中,未免过于冗余,而且也很不优雅。

想想其实就是在每个操作的基础上添加一个功能,各个模块的功能有需要有这个增强功能,多多少少是个横向的意思,那么可以使用spring提供的AOP功能,将需要生成日志的方法作为切入点,然后编写增强代码即生成日志和插入日志的部分即可。

设计

操作日志存到数据库中,日志表的字段主要有自增id、用户名、操作方法、url、操作时间等。

基于Spring AOP的日志功能实现_第1张图片

代码实现

利用mybatis-plugin生成domain mapper和mapper.xml文件






    
    

    
    
        
        
            
            
            
            
        
        
        
            
        
        
            
        
        
        
            
            
            
            
            

            

        
        
        









        
        

这里需要注意一点是,如果一个项目中有多个模块,后续添加某个表旧一定要注释掉配置文件中的自动生成mapper和mapper.xml文件,否则会将之前手动添加的方法覆盖掉(当然也可以配置不要覆盖)

通过配置前置通知和后置通知实现日志:

@Component
@Aspect
public class LogAop {
    @Autowired
    HttpServletRequest request;
    @Autowired
    SysLogService sysLogService;

    private Date visitTime; //开始时间
    private Class aClass; //访问的类
    private String methodName; //访问的方法名
    private String username; //用户名

    //前置通知,主要获取开始时间、执行的类是哪个、执行的是哪一个方法(对于切入点中的路径根据实际项目中的路径编写)
    @Before("execution(* com.practice.mall.controller.*.*.*(..))")
    public void doBefore(JoinPoint joinPoint) throws NoSuchMethodException {
        visitTime = new Date(); //当前时间即开始访问的时间
        aClass = joinPoint.getTarget().getClass(); //具体要访问的类
        methodName = joinPoint.getSignature().getName(); //获取访问的方法的名称

        //例如登录功能,需要在切入点执行前获取用户名;功能执行之后HttpSession中无法获取用户名
        if(request.getSession().getAttribute(Const.CURRENT_USER)!=null){
            username = ((MallUser)request.getSession().getAttribute(Const.CURRENT_USER)).getUsername();
        }

    }

    //后置通知 execution中的第一个*表示任意方法返回值 第二个*表示backend or portal 第三个*表示xxxController 第四个*表示xxxcontroller中的方法
    @After("execution(* com.practice.mall.controller.*.*.*(..))")
    public void doAfter(JoinPoint joinPoint){
        long time = new Date().getTime() - visitTime.getTime(); //获取访问时长

        //获取url
        String url = "";
        if(aClass != null && methodName != null && !StringUtils.equals(methodName,"/listSysLog") && aClass != LogAop.class){ //根据实际需求可过滤掉不需要记录到日志中的method
            //利用反射获取类上的@RequestMapping 注解也是一个类

            //获取类上的value值
            RequestMapping classAnnotation = (RequestMapping)aClass.getAnnotation(RequestMapping.class);
            if(classAnnotation != null){
                String[] classValue = classAnnotation.value();

                url = classValue[0] + "/" + methodName;

                //获取访问的ip
                String ip = request.getRemoteAddr();

                if(StringUtils.isBlank(username) && request.getSession().getAttribute(Const.CURRENT_USER)!=null){
                    username = ((MallUser)request.getSession().getAttribute(Const.CURRENT_USER)).getUsername();
                }else if(StringUtils.isBlank(username)){
                    username = "test";
                }
                //将日志相关信息封装到sysLog对象中
                SysLog sysLog = new SysLog();
                sysLog.setUsername(username);
                sysLog.setVisitTime(visitTime);
                sysLog.setIp(ip);
                sysLog.setUrl(url);
                sysLog.setMethod("[类名]" + aClass.getName() + "[方法名]" + methodName); //有时候aclass和method可能为null
                sysLog.setExecutePeriod(time);
                sysLogService.addSysLog(sysLog);
            }
        }
    }
}

可以改为环绕通知方法实现

@Component
@Aspect
public class LogAop {
    @Autowired
    HttpServletRequest request;
    @Autowired
    SysLogService sysLogService;

    private Date visitTime; //开始时间
    private Class aClass; //访问的类
    private String methodName;
    private String username; //用户名

    @Around("execution(* com.practice.mall.controller.*.*.*(..))")
    public void around(ProceedingJoinPoint joinPoint){
        
        //前置通知
        visitTime = new Date(); //当前时间即开始访问的时间
        aClass = joinPoint.getTarget().getClass(); //具体要访问的类
        methodName = joinPoint.getSignature().getName(); //获取访问的方法的名称

        if(request.getSession().getAttribute(Const.CURRENT_USER)!=null){
            username = ((MallUser)request.getSession().getAttribute(Const.CURRENT_USER)).getUsername();
        }

    
        //执行切入点操作
        try {
            joinPoint.proceed(joinPoint.getArgs());
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }

        //后置通知
        long time = new Date().getTime() - visitTime.getTime(); //获取访问时长

        //获取url
        String url = "";
        if(aClass != null && methodName != null && !StringUtils.equals(methodName,"/listSysLog") && aClass != LogAop.class) { //根据实际需求可过滤掉不需要记录到日志中的method
            //利用反射获取类上的@RequestMapping 注解也是一个类

            //获取类上的value值
            RequestMapping classAnnotation = (RequestMapping) aClass.getAnnotation(RequestMapping.class);
            if (classAnnotation != null) {
                String[] classValue = classAnnotation.value();
                url = classValue[0] + "/" + methodName;

                //获取访问的ip
                String ip = request.getRemoteAddr();

                if (StringUtils.isBlank(username) && request.getSession().getAttribute(Const.CURRENT_USER) != null) {
                    username = ((MallUser) request.getSession().getAttribute(Const.CURRENT_USER)).getUsername();
                } else if (StringUtils.isBlank(username)) {
                    username = "test";
                }

                //将日志相关信息封装到sysLog对象中
                SysLog sysLog = new SysLog();
                sysLog.setUsername(username);
                sysLog.setVisitTime(visitTime);
                sysLog.setIp(ip);
                sysLog.setUrl(url);
                sysLog.setMethod("[类名]" + aClass.getName() + "[方法名]" + methodName); //有时候aclass和method可能为null
                sysLog.setExecutePeriod(time);
                sysLogService.addSysLog(sysLog);
            }
        }
        
    }
}

优化

其实项目中并不是所有的controller方法都需要记录操作日志,如此处理未免有些莽撞,可以通过给controller层或者其他层的方法添加注解的方法,织入通知,完成日志的生成和表数据插入即可

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface SysLogAnnotation {
}
@Component
@Aspect
public class LogAop {
    @Autowired
    HttpServletRequest request;
    @Autowired
    SysLogService sysLogService;

    private Date visitTime; //开始时间
    private Class aClass; //访问的类
    private String methodName;
    private String username; //用户名

    @Around(value = "@annotation(sysLogAnnotation)")
    public void logAround(final ProceedingJoinPoint joinPoint, final SysLogAnnotation sysLogAnnotation){
        //前置通知
        visitTime = new Date(); //当前时间即开始访问的时间
        aClass = joinPoint.getTarget().getClass(); //具体要访问的类
        methodName = joinPoint.getSignature().getName(); //获取访问的方法的名称

        if(request.getSession().getAttribute(Const.CURRENT_USER)!=null){
            username = ((MallUser)request.getSession().getAttribute(Const.CURRENT_USER)).getUsername();
        }

        //执行切入点操作
        try {
            if(joinPoint.getArgs().length == 0){
                joinPoint.proceed();
            }else {
                joinPoint.proceed(joinPoint.getArgs());
            }
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }

        //后置通知
        long time = new Date().getTime() - visitTime.getTime(); //获取访问时长

        //获取url
        String url = "";
        if(aClass != null && methodName != null && !StringUtils.equals(methodName,"listSysLog")) { //根据实际需求可过滤掉不需要记录到日志中的method
            //利用反射获取类上的@RequestMapping 注解也是一个类

            //获取类上的value值
            RequestMapping classAnnotation = (RequestMapping) aClass.getAnnotation(RequestMapping.class);
            if (classAnnotation != null) {
                String[] classValue = classAnnotation.value();
                url = classValue[0] + "/" + methodName;

                //获取访问的ip
                String ip = request.getRemoteAddr();

                if (StringUtils.isBlank(username) && request.getSession().getAttribute(Const.CURRENT_USER) != null) {
                    username = ((MallUser) request.getSession().getAttribute(Const.CURRENT_USER)).getUsername();
                } else if (StringUtils.isBlank(username)) {
                    username = "test";
                }

                //将日志相关信息封装到sysLog对象中
                SysLog sysLog = new SysLog();
                sysLog.setUsername(username);
                sysLog.setVisitTime(visitTime);
                sysLog.setIp(ip);
                sysLog.setUrl(url);
                sysLog.setMethod("[类名]" + aClass.getName() + "[方法名]" + methodName); //有时候aclass和method可能为null
                sysLog.setExecutePeriod(time);
                sysLogService.addSysLog(sysLog);
            }
        }

    }
}

然后在需要记录操作日志的方法上加@SysLogAnnotation注解即可,执行后数据库添加一条记录

基于Spring AOP的日志功能实现_第2张图片

当然如果不同的方法有自定义的一些字段例如涉及操作的数据库表名等信息,可以通过在注解中添加属性,然后从注解中获得。

具体参考:https://blog.csdn.net/t_jindao/article/details/85259145

测试

执行多个方法后的数据库日志表

基于Spring AOP的日志功能实现_第3张图片

也支持通过接口查询所有的操作日志

基于Spring AOP的日志功能实现_第4张图片

拓展

1.  注意如果调用环绕通知方法的时候报错:org.springframework.aop.AopInvocationException: Null return value from advice does not match primitive return type for....

主要是因为环绕方法的返回值为void,而实际需要增强的切入点方法返回值为object,返回值类型不匹配所以报错。(反向代理的主要过程就是先实现增强的部分,最后通过代理实现被代理的方法,所以一般而言被代理方法和代理方法的返回值需要保持一致),解决方法是将环绕方法的返回值由void改为Object即可(默认代理的时候会将Object根据切入点方法返回值类型的不同进行类型强制转换)。

参考链接:https://blog.csdn.net/fdk2zhang/article/details/82987497

 

2.  另外博客中的ip没有进行处理,对于ip的获取可以参考:https://blog.csdn.net/qq_36411874/article/details/79938439

    /**
     * @param request
     * @return
     */
    public static String getIpAddress(HttpServletRequest request) {
        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_CLIENT_IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("X-Real-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        if("0:0:0:0:0:0:0:1".equals(ip)){
            ip = "127.0.0.1";
        }
        return ip;
    }

 

3.  再拓展一个前端Get和Post请求的区别,参考:

https://blog.csdn.net/kelly0721/article/details/88415806

https://mp.weixin.qq.com/s/4_IQcjcrsRS0iZ2ze7nQRA

https://mp.weixin.qq.com/s?__biz=MzI3NzIzMzg3Mw==&mid=100000054&idx=1&sn=71f6c214f3833d9ca20b9f7dcd9d33e4#rd

我自己的感觉是本质上对于获取数据没什么区别,表面上的区别是get是从服务器获取数据,post是发送数据到服务器中。

但实际对于需求功能而言无论是get和post都是发送数据到服务器,然后从服务器中拿取数据,只不过就是主体不一样。如果重点在于从服务器中拿取数据那么用get;如果重点在于发送数据到服务器,那么用post。另外对于一些文件上传,还有后端controller方法的参数加@RequestBody的时候,也使用post而非get。

一般对数据库做查询操作推荐使用get,对数据库做写操作(新增、更新、删除)推荐使用post,另外对于一些不想在url中展示参数可使用post。登录功能一般都使用的是post,是不是也是考虑到username和password安全性的问题呢?(目前浅显学习之后的一家之言,欢迎讨论~)

 

总结

学习的基本策略:基础概念、实战操作、底层原理以及日常结合其他方面的各种练习总结。

你可能感兴趣的:(Java学习,后端开发)