SpringBoot - AOP+自定义注解实现一个日志插件

说在前面

本日志记录涵盖:IP地址(详细)、Header信息、Session信息、方法参数、返回结果、请求方式(POST/GET)、URI等等…
通过自定义注解的思想,根据需求可以添加更多日志字段。
将日志功能做成一个插件,做到即引即用。

插件书写

根据自己的实际需求,对插件代码进行修改。

1、引入相关依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.46</version>
</dependency>
2、创建自定义注解

这个自定义注解作用的作用就是:你可以通过自定义注解来传入一些你想要的记录的日志参数,比如我们调用一个POST类型的Controller接口,我们想记录它的请求方式、它是哪个模块的接口、作用是什么时,我们可以通过这个自定义注解传进去,然后通过在AOP的时候通过反射获取。

// 注解放置的目标位置,METHOD是可注解在方法级别上
@Target(ElementType.METHOD)
//注解在哪个阶段执行 - 运行时
@Retention(RetentionPolicy.RUNTIME)
public @interface OperateLog {
    /** 操作模块(如订单模块) */
    String operateModule() default "";
    /** 操作说明(如新增订单) */
    String operateDesc() default "";
    /** 请求类型(GET/POST/PUT/DELETE) */
    String operateType() default "";
    // 更多补充...
}
3、创建两个日志Pojo
创建一个BaseLog

该类主要就是作为正常操作和异常操作日志的基类,即两个日志共有的属性。

@Data
@ToString
public class BaseLog {
    /** 分布式id(自增id) */
    private Long id;
    /** 请求参数 */
    private String requestParam;
    /** 请求头参数 */
    private String headerParam;
    /** 请求方法名 */
    private String operateMethod;
    /** 操作用户ID */
    private Long operateUserId;
    /** 操作用户名 */
    private String operateUserName;
    /** 请求URI(如/test/add) */
    private String operateUri;
    /** 请求IP */
    private String operateIp;
    /** 操作模块 */
    private String operateModule;
    /** 请求类型(GET/POST/PUT/DELETE) */
    private String operateType;
    /** 操作描述 */
    private String operateDesc;
    /** 创建时间 */
    private Date createTime;
}
正常调用记录日志 - OperationLog
@Data
@ToString
public class OperationLog extends BaseLog{
    /** 返回结果 */
    private String operateResponseParam;
}
异常调用记录日志 - ExceptionLog
@Data
@ToString
public class ExceptionLog extends BaseLog{
    /** 异常名 */
    private String excName;
    /** 异常信息 */
    private String excMessage;
}
4、提供两个接口

这两个接口就是对正常操作日志和异常操作日志的处理,把他交给引入我这个插件的人去实现,接口只负责把日志对象信息传过去。
引入我这个插件的,必须要实现这两个接口,且需要将其实现类交给Spring管理(比如使用@Component标记)。

public interface OperationLogService {
    /**
     * 将日志记录到数据库
     * @param operationLog 日志对象
     */
    void recordOperateLog(OperationLog operationLog);
}
@Component
public class ExceptionLogServiceImpl implements ExceptionLogService {
    @Override
    public void recordExceptionLog(ExceptionLog exceptionLog) {
        // 这里再调用一个存储数据库的service即可
        System.out.println("正在记录数据库日志 - 异常");
    }
}
5、创建切面
@Aspect
@Component
@Slf4j
public class OperationLogAspect {
    private static final String COMMA = ",";
    private static final String IP = "127.0.0.1";
    private static final String LOCAL_IP = "0:0:0:0:0:0:0:1";
    /**
     * x-forwarded-for是识别通过HTTP代理或负载均衡方式连接到Web服务器的客户端
     * 最原始的IP地址的HTTP请求头字段
     */
    private static final String HEADER = "x-forwarded-for";
    private static final String UNKNOWN = "unknown";
    /**
     * 经过apache http服务器的请求才会有
     * 用apache http做代理时一般会加上Proxy-Client-IP请求头
     * 而WL-Proxy-Client-IP是它的weblogic插件加上的头。
     */
    private static final String WL_IP = "WL-Proxy-Client-IP";
    private static final Integer IP_LENGTH = 15;

    @Autowired
    private OperationLogService operationLogService;
    @Autowired
    private ExceptionLogService exceptionLogService;

    /**
     * 设置操作日志切入点记录操作日志
     * 在注解的位置切入代码
     */
    @Pointcut("@annotation(pers.liuchengyin.log.OperateLog)")
    public void operateLogPointCut() {

    }

    /**
     * 设置操作异常切入点记录异常日志
     * 扫描所有controller结尾的操作
     * 根据需要修改,一般只需要修改前缀即可,比如你是com.开头的包,将pers改成com即可
     */
    @Pointcut(value = "execution(* pers..*Controller.*.*(..))")
    public void operateExcLogPointCut() {

    }


    /*
     * 1、获取参数问题(Body和Query),通过request.getParameterMap()只能获取到Query参数
     *    无法获取到Body,如果通过流的方式获取Body,会出现 getInputStream() has already been called for this request 异常
     * 2、异常原因:@RequestBody这个注解是以流的形式读取请求,它调用过一次了。(getInputStream()和getReader()只能调用一次)
     *    解决办法:暂不清楚
     * 3、获参解决方案:可以通过request.getParameterMap()获取,但只能获取到query参数,无法获取到body参数
     *    可以考虑通过request.getParameterMap()来判断是否为空,为空,则可能是传的body参数
     *       问题:(@RequestBody User user, String name, Integer age)  无法解决,没办法通过判空来解决
     * 4、最终解决方案:统一采用joinPoint.getArgs()来获取参数
     */


    /**
     * 正常返回通知,拦截用户操作日志,连接点正常执行完成后执行,如果连接点抛出异常,则不会执行
     * @param joinPoint 切入点
     * @param keys      返回结果
     */
    @AfterReturning(value = "operateLogPointCut()", returning = "keys")
    public void saveOperationLog(JoinPoint joinPoint, Object keys) {
        OperationLog operationLog = new OperationLog();
        try {
            setBaseLog(joinPoint, operationLog);
            operationLog.setOperateResponseParam(JSON.toJSONString(keys));
            // 插入数据库
            operationLogService.recordOperateLog(operationLog);
            // 打印信息,请删除,做测试使用,请使用log.info做日志
            System.out.println(operationLog);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


    @AfterThrowing(pointcut = "operateExcLogPointCut()", throwing = "e")
    public void saveExceptionLog(JoinPoint joinPoint, Throwable e) {
        ExceptionLog exceptionLog = new ExceptionLog();
        try {
            setBaseLog(joinPoint, exceptionLog);
            exceptionLog.setExcName(e.getClass().getName());
            exceptionLog.setExcMessage(stackTraceToString(e.getClass().getName(), e.getMessage(), e.getStackTrace()));
            // 插入数据库
            exceptionLogService.recordExceptionLog(exceptionLog);
            // 打印信息,请删除,做测试使用,请使用log.info做日志
            System.out.println(exceptionLog);
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }


    /**
     * 设置基础的参数
     * @param joinPoint 切入点
     * @param baseLog 基础日志
     */
    private void setBaseLog(JoinPoint joinPoint, BaseLog baseLog) {
        // 获取RequestAttributes
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        // 从获取RequestAttributes中获取HttpServletRequest的信息
        HttpServletRequest request = (HttpServletRequest) requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
        // 获取Session信息 - 如果你需要的话
//        HttpSession session = (HttpSession) requestAttributes.resolveReference(RequestAttributes.REFERENCE_SESSION);
        baseLog.setId(1L);
        // 从切面织入点通过反射机制获取织入点的方法
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        // 获取切入点所在的方法对象
        Method method = signature.getMethod();
        setOpLog(method, baseLog);
        String methodName = getMethodName(joinPoint, method);
        String params = getParams(joinPoint);
        String headerParams = getHeaders(request);
        baseLog.setOperateMethod(methodName);
        baseLog.setRequestParam(params);
        baseLog.setHeaderParam(headerParams);
        // TODO: 参数设置 - 根据自己的需求添加即可
        // 比如请求用户的信息,可以通过一个工具类获取,从ThreadLocal里拿出来
        // 请求用户ID
        baseLog.setOperateUserId(1L);
        // 请求用户名称
        baseLog.setOperateUserName("liuchengyin");
        // 请求IP
        baseLog.setOperateIp(getIpAddr(request));
        // 请求URI
        baseLog.setOperateUri(request.getRequestURI());
        // 创建时间
        baseLog.setCreateTime(new Date());
    }

    /**
     * 设置@OperateLog注解的参数
     * @param method 方法
     * @param baseLog 日志
     */
    private void setOpLog(Method method, BaseLog baseLog){
        // 获取操作信息 - 就是获取@OperateLog里面信息
        OperateLog opLog = method.getAnnotation(OperateLog.class);
        if (null != opLog) {
            // 操作模块 - 如(订单模块)
            baseLog.setOperateModule(opLog.operateModule());
            // 请求类型 - 如(POST)
            baseLog.setOperateType(opLog.operateType());
            // 操作描述 - 如(新增订单)
            baseLog.setOperateDesc(opLog.operateDesc());
        }
    }

    /**
     * 转换异常信息为字符串
     * @param exceptionName    异常名称
     * @param exceptionMessage 异常信息
     * @param elements         堆栈信息
     */
    private String stackTraceToString(String exceptionName, String exceptionMessage, StackTraceElement[] elements) {
        StringBuffer strBuff = new StringBuffer();
        for (StackTraceElement stet : elements) {
            strBuff.append(stet + "\n");
        }
        return exceptionName + ":" + exceptionMessage + "\n\t" + strBuff.toString();
    }

    /**
     * 获取方法名
     * @param joinPoint 切入点
     * @param method 方法对象
     * @return 方法名
     */
    private String getMethodName(JoinPoint joinPoint, Method method){
        // 获取请求的类名
        String className = joinPoint.getTarget().getClass().getName();
        // 获取请求的方法名
        String methodName = method.getName();
        // 拼接类名和方法名 - pers.liuchengyin.controller.xxxController
        methodName = className + "." + methodName;
        return methodName;
    }

    /**
     * 获取方法请求参数
     * @param joinPoint 切入点
     * @return 参数JSON字符串
     */
    private String getParams(JoinPoint joinPoint){
        // 获取参数名
        String[] parameterNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames();
        // 参数的JSON字符串
        String params = "";
        if(null != parameterNames && parameterNames.length != 0){
            // 获取参数
            Object[] args = joinPoint.getArgs();
            // 将 <参数名, 参数> 打入map
            HashMap<String, Object> map = new HashMap<>();
            for (int i = 0; i < parameterNames.length; i++){
                map.put(parameterNames[i],args[i]);
            }
            // 使用FastJson将其转换为字符串
            params = JSON.toJSONString(map);
        }
        return params;
    }

    /**
     * 获取Headers,并将其转换成JSON字符串
     * @param request HttpServletRequest
     * @return headers的JSON字符串
     */
    private String getHeaders(HttpServletRequest request) {
        // 获取请求头参数
        Enumeration<String> headerNames = request.getHeaderNames();
        // 请求头的JSON字符串
        String headerParams = "";
        if(null != headerNames){
            HashMap<String, String> headMap = new HashMap<>(7);
            while(headerNames.hasMoreElements()){
                String key = headerNames.nextElement();
                String value = request.getHeader(key);
                headMap.put(key,value);
            }
            // 使用FastJson将其转换为字符串
            headerParams = JSON.toJSONString(headMap);
        }
        return headerParams;
    }

    /**
     * 获取IP地址
     * @param request HttpServletRequest
     * @return IP地址
     */
    private String getIpAddr(HttpServletRequest request) {
        String ip = request.getHeader(HEADER);
        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_IP);
        }
        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
            if (ip.equals(IP) || ip.equals(LOCAL_IP)) {
                // 根据网卡获取本机配置的IP地址
                InetAddress inetAddress = null;
                try {
                    inetAddress = InetAddress.getLocalHost();
                } catch (UnknownHostException e) {
                    e.printStackTrace();
                }
                if (Objects.nonNull(inetAddress)) {
                    ip = inetAddress.getHostAddress();
                }
            }
        }
        // 对于通过多个代理的情况,第一个IP为客户端真实的IP地址,多个IP按照','分割
        if (null != ip && ip.length() > IP_LENGTH) {
            // "***.***.***.***".length() = 15
            if (ip.indexOf(COMMA) > 0) {
                ip = ip.substring(0, ip.indexOf(COMMA));
            }
        }
        return ip;
    }
    
}

其他工程引入插件方式

1、首先将插件使用maven打到本地仓库

具体操作见百度,下列命令仅作为参考。

// clean install
mvn clean install
// 打包到本地仓库的完整命令,可根据自己需要进行调整
mvn install:install-file -Dfile=target/log-api-1.0.0.jar -DgroupId=pers.liuchengyin.log -DartifactId=log-api -Dversion=1.0.0 -Dpackaging=jar
2、引入插件依赖
<dependency>
    <groupId>pers.liuchengyin.log</groupId>
    <artifactId>log-api</artifactId>
    <version>1.0.0</version>
</dependency>
3、实现接口
@Component
public class LogInfoWriteServiceImpl implements OperationLogService {
    @Override
    public void recordOperateLog(OperationLog operationLog) {
        // 这里再调用一个存储数据库的service即可
        System.out.println("正在记录数据库日志 - 正常");
    }
}
@Component
public class ExceptionLogServiceImpl implements ExceptionLogService {
    @Override
    public void recordExceptionLog(ExceptionLog exceptionLog) {
        // 这里再调用一个存储数据库的service即可
        System.out.println("正在记录数据库日志 - 异常");
    }
}
4、Controller方法加上注解即可
@RequestMapping("/two/test")
@RestController
public class TestTwoController {
    @GetMapping("/info")
    @OperateLog(operateModule = "用户管理", operateType = "GET", operateDesc = "获取用户")
    public Response<User> info(String name, Integer age){
        User user = new User(1L, name,"女", age);
        return new Response<User>(true,"查询成功",user);
    }
}
5、测试
http://localhost:8080/two/test/info?name=liuchengyin&age=18

在这里插入图片描述

Github地址:SpringBoot + AOP 实现一个日志记录插件Demo
Gitee地址:SpringBoot + AOP 实现一个日志记录插件Demo
参考博客:参考博文
感谢作者提供的一个自定义注解的思想,代码参照原博客,对代码做了一些优化调整以及一些自己的见解。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值