Springboot使用aop技術(記錄操作日誌)

1. 前言

        有一段時間沒有使用面向切面編程(aop),有點生疏。恰好項目有在執行方法前後需要預處理和返回處理的需求,所以打算藉機重拾起來。由於保密的原因,加上aop的原理大同小異,我還是從萬年不變的日誌記錄開始aop的複習吧~

2. 準備工作

2.1.表格創建

2.1.1操作日誌表

CREATE TABLE `operation_log` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵id',
  `user_id` varchar(64) DEFAULT NULL COMMENT '用戶id',
  `user_name` varchar(100) DEFAULT NULL COMMENT '用戶名',
  `type` varchar(64) DEFAULT NULL COMMENT '請求類型',
  `module` varchar(100) DEFAULT NULL COMMENT '請求模塊',
  `request_des` varchar(500) DEFAULT NULL COMMENT '請求描述',
  `method_name` varchar(255) DEFAULT NULL COMMENT '請求方法名',
  `ip` varchar(20) DEFAULT NULL COMMENT '請求ip',
  `url` varchar(255) DEFAULT NULL COMMENT '請求地址',
  `request_param` varchar(255) DEFAULT NULL COMMENT '請求參數',
  `return_data` varchar(255) DEFAULT NULL COMMENT '返回值',
  `start_time` varchar(50) DEFAULT NULL COMMENT '請求開始時間',
  `finish_time` varchar(50) DEFAULT NULL COMMENT '請求完成時間',
  `return_time` varchar(50) DEFAULT NULL COMMENT '接口返回時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8;

2.1.2異常日誌表

CREATE TABLE `exception_log` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵id',
  `user_id` varchar(64) DEFAULT NULL COMMENT '用戶id',
  `user_name` varchar(100) DEFAULT NULL COMMENT '用戶名',
  `exception_name` varchar(255) DEFAULT NULL COMMENT '異常名稱',
  `exception_msg` varchar(6000) DEFAULT NULL COMMENT '異常描述',
  `method_name` varchar(255) DEFAULT NULL COMMENT '請求方法名',
  `ip` varchar(20) DEFAULT NULL COMMENT '請求ip',
  `url` varchar(255) DEFAULT NULL COMMENT '請求地址',
  `request_param` varchar(255) DEFAULT NULL COMMENT '請求參數',
  `create_time` varchar(50) DEFAULT NULL COMMENT '異常產生時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;

        對應的entity、service和mapper在這裏省略不寫了,可以直接代碼生成。
        另外IpAdressUtil是用於獲取ip地址,這裏就不提供了。

3. 實現日誌切面

3.1 導入依賴

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

3.2 創建操作日誌註解類

可以在切面類中用切入點表達式指定使用的類或方法,也可以創建一個註解用於指定需要記錄日誌的方法。後者更加靈活,同時可以添加額外的說明

@Target({ElementType.METHOD,ElementType.ANNOTATION_TYPE}) //註解可使用在方法、類/接口級別上
@Retention(RetentionPolicy.RUNTIME) //註解執行的階段爲運行時,以便在運行時獲取註解信息
@Inherited // 該註解可以被子類繼承,非必須
@Documented // 執行javadoc會自動生成文檔,非必須
public @interface OperLog {
    String type() default ""; // 操作類型
    String module() default ""; // 操作模塊
    String requestDes() default ""; // 操作描述
}

3.3 創建切面類

3.3.1 joinPoint和ProceedingJoinPoint接口

        在創建切面類之前,先了解一下JoinPoint和ProceedingJoinPoint的相關知識吧
        JoinPoint接口用於表示目標類連接對象,也就是說可以通過JoinPoint實例獲取目標對象的相關信息。而在使用環繞通知的時候需要使用ProceedingJoinPoint,作爲JoinPoint的子接口,除了繼承JoinPoint的方法,還新增一個proceed方法用於通過反射執行目標對象的連接點處方法。常用的方法如下:

  1. joinPoint.getTarget():獲取切入點所在的目標對象。

  2. joinPoint.getSignature():獲取被織入增強的方法簽名

    • joinPoint.getSignature().getName():返回方法名字
    • joinPoint.getSignature().getDeclaringType():返回包名+組件名(類名)
    • ((MethodSignature) joinPoint.getSignature()).getMethod():返回Method對象
  3. joinPoint.getThis():獲取代理對象本身,所包含的信息和getTarget的返回對象一致

  4. jointpoint.getArgs():獲取切入點方法運行時的入參列表,注意返回的列表中只包含參數的值

3.3.2 切面類示例

import com.alibaba.fastjson.JSON;
import com.javatest.po.ExceptionLog;
import com.javatest.po.OperationLog;
import com.javatest.service.ExceptionLogService;
import com.javatest.service.OperationLogService;
import com.javatest.util.IpAdressUtil;
import com.javatest.util.annotation.OperLog;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Aspect // 指定當前類爲切面類
@Component // 創建切面類對象
public class LoggerAspect {

    private final Logger logger = LoggerFactory.getLogger(LoggerAspect.class);
    @Autowired
    private OperationLogService operationLogService;
    @Autowired
    private ExceptionLogService exceptionLogService;

    // 定義請求日誌切入點表達式,掃描被@OperLog註解的方法
    @Pointcut("@annotation(com.javatest.util.annotation.OperLog)")
    public void operLogPt() {
    }

    // 定義異常日誌切入點表達式,掃描controller包下所有方法
    @Pointcut("execution(* com.javatest.controller.*.*(..))")
    public void excepLogPt() {
    }

    // 正常的請求日誌是在返回時記錄所有數據的
    @AfterReturning(value = "operLogPt()",returning = "returnData")
    public void addOperLog(JoinPoint joinPoint, Object returnData) {
        try {
            HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
            OperationLog operationLog = new OperationLog();

            // 注意,請求的數據要從切入點中反射出來
            //從切面織入點處通過反射機制獲取織入點處的方法
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            // 獲取切入點所在的方法
            Method method = signature.getMethod();

            // 獲取註解上的信息
            OperLog annotation = method.getAnnotation(OperLog.class);
            if (annotation != null) {
                operationLog.setType(annotation.type());
                operationLog.setModule(annotation.module());
                operationLog.setRequestDes(annotation.requestDes());
            }

            // 獲取請求的類路徑
            String className = joinPoint.getTarget().getClass().getName();
            // 獲取請求的方法名
            String methodName = method.getName();
            methodName = className + "." + methodName;

            // 獲取POST請求參數
            Map<String,String> paramMap = new HashMap<>();
            Map<String, String[]> parameterMap = request.getParameterMap();
            for (String key : parameterMap.keySet()) {
                paramMap.put(key,parameterMap.get(key)[0]);
            }
            String param = JSON.toJSONString(paramMap);

            // 完善信息,id爲int類型自增。可改用String傳uuid
            // 從session中獲取,本地測試寫死
// operationLog.setUserId((String) request.getSession().getAttribute("userId"));
// operationLog.setUserName((String) request.getSession().getAttribute("userName"));
            operationLog.setUserId("1");
            operationLog.setUserName("azure");
            operationLog.setIp(IpAdressUtil.getIpAddr(request));
            operationLog.setUrl(request.getRequestURI());
            operationLog.setMethodName(methodName);
            operationLog.setRequestParam(param);
            operationLog.setReturnData(JSON.toJSONString(returnData));
// operationLog.setStartTime();
// operationLog.setFinishTime();
            operationLog.setReturnTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date()));

            operationLogService.insertSelective(operationLog);
        } catch (Exception e) {
            logger.error("插入日誌失敗",e);
        }
    }

    @AfterThrowing(pointcut = "excepLogPt()",throwing = "e")
    public void addExcepLog(JoinPoint joinPoint, Throwable e){
        try {
            HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
            ExceptionLog exceptionLog = new ExceptionLog();

            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();
            String className = joinPoint.getTarget().getClass().getName();
            String methodName = method.getName();
            methodName = className + "." + methodName;

            // 獲取請求參數
            Object[] args = joinPoint.getArgs();
            String param = JSON.toJSONString(args);

            // 獲取異常信息
            StringBuilder sb = new StringBuilder();
            sb.append(e.getMessage()).append(";\n");
            for (StackTraceElement element : e.getStackTrace()) {
                sb.append(element).append("\n");
            }

            //完善數據,id爲int類型自增
// exceptionLog.setId();
            // 同上
            exceptionLog.setUserId("1");
            exceptionLog.setUserName("azure");
            exceptionLog.setExceptionName(e.getClass().getName());
            exceptionLog.setExceptionMsg(sb.toString());
            exceptionLog.setMethodName(methodName);
            exceptionLog.setIp(IpAdressUtil.getIpAddr(request));
            exceptionLog.setUrl(request.getRequestURI());
            exceptionLog.setRequestParam(param);
            exceptionLog.setCreateTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date()));

            exceptionLogService.insertSelective(exceptionLog);
        } catch (Exception e1) {
            logger.error("插入異常日誌失敗",e1);
        }
    }
}

3.4 創建一個簡單的controller類,用來模擬請求

@RestController
@RequestMapping("/aop")
public class AopController {

    @GetMapping("/head/{id}/{Pass}")
    @OperLog(type = "測試",module = "Aop",requestDes = "測試正常情況下的日誌記錄")
    public String head(@PathVariable("id") String id, @PathVariable("Pass") String password){
        System.out.println(id);
        System.out.println(password);
        return "head";
    }

    @PostMapping("/head2")
    @OperLog(type = "測試",module = "Aop",requestDes = "測試正常情況下的日誌記錄")
    public String head2(String id, String password){
// System.out.println(id);
// System.out.println(password);
        return "head";
    }

    @RequestMapping("/error")
    public String error(){
        int i = 1/0;
        return "error";
    }
}

3.5 測試結果

3.5.1 操作日誌結果

post請求可以正常記錄操作日誌。但get請求如果含入參時,操作日誌雖可以記錄,但不能把入參記錄下來。

如果將獲取請求參數的方法由request.getParameterMap()改爲joinPoint.getArgs(),雖然可以將get請求的入參記錄下來,但只記錄下入參的值,沒有記錄入參名。其優化方法還需要進一步的研究

3.5.2 異常日誌結果

均能記錄異常日誌

優化1:改用環繞通知記錄方法執行時間

        網上有些方法是創建一個對象在前置通知中封裝一些參數,在後置通知中處理這個對象;有些是將參數封裝到session中,通過sessionid來獲取session中的參數等。不過用環繞通知可能更容易實現,同時還能避免一些併發問題。

        我們嘗試對上面的切面類進行改造。正如3.3.1所述,使用環繞通知時需使用ProceedingJoinPoint。

優化1.1 環繞通知的格式

@Aspect // 指定當前類爲切面類
@Component // 創建切面類對象
public class Logger {
    // 定義一個切入點表達式
    @Pointcut("execution(切入點表達式)")
    public void pt() {
    }
    
 // 環繞目標對象方法執行
    @Around("pt()")
    public Object around(ProceedingJoinPoint pjp) {
        try {
            System.out.println("[環繞通知] 環繞前");
            Object retV = pjp.proceed();
            System.out.println("[環繞通知] 環繞後");
            return retV;
        } catch (Throwable throwable) {
            throwable.printStackTrace();
            System.out.println("[環繞通知] 環繞異常");
            return null;
        } finally {
            System.out.println("[環繞通知] 環繞最終");
        }
    }
}

        需要注意的是,使用環繞通知需要將目標方法的執行結果返回,否則前端將接收不到執行結果。

優化1.2 環繞通知改造實例

import com.alibaba.fastjson.JSON;
import com.javatest.po.ExceptionLog;
import com.javatest.po.OperationLog;
import com.javatest.service.ExceptionLogService;
import com.javatest.service.OperationLogService;
import com.javatest.util.IpAdressUtil;
import com.javatest.util.annotation.OperLog;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Aspect // 指定當前類爲切面類
@Component // 創建切面類對象
public class LoggerAspect2 {

    private final Logger logger = LoggerFactory.getLogger(LoggerAspect2.class);
    @Autowired
    private OperationLogService operationLogService;
    @Autowired
    private ExceptionLogService exceptionLogService;

    // 定義請求日誌切入點表達式,掃描被@OperLog註解的方法
    @Pointcut("execution(* com.javatest.controller.*.*(..)) || @annotation(com.javatest.util.annotation.OperLog)")
    public void operLogPt() {
    }

    //正常的請求日誌是在返回時記錄所有數據的
    @Around(value = "operLogPt()")
    public Object addOperLog(ProceedingJoinPoint joinPoint) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        OperationLog operationLog = new OperationLog();
        Object object = null;
        try {

            /*【環繞通知】 環繞前*/

            // 獲取通知前的時間
            operationLog.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date()));
            // 獲取被織入增強的方法簽名對象
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            // 獲取請求類路徑+方法名
            String methodName = signature.getDeclaringTypeName() + "." + signature.getName();
            operationLog.setMethodName(methodName);

            // 獲取註解上的信息
            OperLog annotation = signature.getMethod().getAnnotation(OperLog.class);
            if (annotation != null) {
                operationLog.setType(annotation.type());
                operationLog.setModule(annotation.module());
                operationLog.setRequestDes(annotation.requestDes());
            }

            // 獲取POST請求參數
            Map<String, String> paramMap = new HashMap<>();
            Map<String, String[]> parameterMap = request.getParameterMap();
            for (String key : parameterMap.keySet()) {
                paramMap.put(key, parameterMap.get(key)[0]);
            }
            String param = JSON.toJSONString(paramMap);
            operationLog.setRequestParam(param);

            // 執行方法
            try {
                object = joinPoint.proceed();

                operationLog.setFinishTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date())); // 方法執行完時間

                /*【環繞通知】 環繞後*/
                try {
                    // 完善信息,id爲int類型自增。可改用String傳uuid
                    operationLog.setUserId((String) request.getSession().getAttribute("userId"));
                    operationLog.setUserName((String) request.getSession().getAttribute("userName"));
                    operationLog.setIp(IpAdressUtil.getIpAddr(request));
                    operationLog.setUrl(request.getRequestURI());
                    operationLog.setMethodName(methodName);
                    operationLog.setRequestParam(param);
                    operationLog.setReturnData(JSON.toJSONString(object));
                    operationLog.setReturnTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date()));

                    operationLogService.insertSelective(operationLog);
                } catch (Exception e) {
                    logger.error("插入操作日誌失敗", e);
                }

                // 注意:環繞通知必須返回方法執行的結果
            } catch (Throwable e) {
                /*【環繞通知】 環繞異常*/
                e.printStackTrace();

                // 插入異常日誌
                ExceptionLog exceptionLog = new ExceptionLog();
                StringBuilder sb = new StringBuilder();
                sb.append(e.getMessage()).append(";\n");
                for (StackTraceElement element : e.getStackTrace()) {
                    sb.append(element).append("\n");
                }
                try {
                    exceptionLog.setUserId((String) request.getSession().getAttribute("userId"));
                    exceptionLog.setUserName((String) request.getSession().getAttribute("userName"));
                    exceptionLog.setExceptionName(e.getClass().getName());
                    exceptionLog.setExceptionMsg(sb.toString());
                    exceptionLog.setMethodName(methodName);
                    exceptionLog.setIp(IpAdressUtil.getIpAddr(request));
                    exceptionLog.setUrl(request.getRequestURI());
                    exceptionLog.setRequestParam(param);
                    exceptionLog.setCreateTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date()));

                    exceptionLogService.insertSelective(exceptionLog);
                } catch (Exception e1) {
                    logger.error("插入異常日誌失敗", e1);
                }
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
        return object;
    }
}

       由於環繞異常會將方法執行外拋的異常捕獲,如果需要方法的異常來觸發某些機制的話,將會與環繞異常的異常捕獲產生衝突。但一般來說,異常不建議作爲業務流程的一部分。

優化2:多aop切面下的優先級

Aspect切面類上加上@Order(n)註解即可,n越小最先執行,也就是位於最外層。

參考文獻:

Spring Boot中使用AOP記錄請求日誌
SpringBoot使用AOP記錄請求日誌和異常日誌
Spring boot中使用aop詳解
使用SpringBoot AOP 記錄操作日誌、異常日誌
springAOP實現操作日誌記錄,並記錄請求參數與編輯前後字段的具體改變
spring中用joinpoint來訪問目標方法的參數

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章