需求:為系統中所有的提交,修改,刪除等等操作(除查詢以外的所有操作)做日誌記錄,記錄的內容包括:請求引數,返回引數,如果報錯就儲存報錯資訊。日誌要新增一個日誌型別。
方案:最好的選擇就是用註解標記切點,用AOP實現此需求。

一、準備

版本:

  • JDK1.8
  • Spring-5.0.5.RELEASE
  • SpringBoot-2.0.1.RELEASE
  • mybatis-3.4.5
  • mybatis-spring-boot-starter-1.3.2
  • mysql8

日誌表(log)表結構:
這裡寫圖片描述

二、解析

日誌按功能分類,定義一個列舉類,用於在切點註解上標記日誌型別。儲存日誌資訊的時候儲存到type欄位上。
日誌型別列舉類:LogType.java
標記此操作是什麼型別,比如,增加使用者,刪除使用者、、、、

/**
 * Description:
 * User: RoronoraZoro丶WangRui
 * Date: 2018-09-03
 * Time: 下午4:20
 */
public enum LogType {
    /**
     * 應用-增加
     */
    APP_ADD(30001),
    /**
     * 部署-部署應用
     */
    DEP_ADD(40001),
    /**
     * 部署-回滾應用
     */
    DEP_ROLLBACK(40002),

    private int value;

    LogType(int value) {
        this.value = value;
    }

    LogType(String value) {
        for (LogType item : values()) {
            if (item.name().equals(value)) {
                this.value = item.value;
            }
        }
        throw new IllegalArgumentException("Invalid type value");
    }

    public int value() {
        return value;
    }

    public static LogType valueOf(int value) {
        for (LogType item : values()) {
            if (item.value() == value) {
                return item;
            }
        }
        throw new IllegalArgumentException("Invalid type value");
    }
}

日誌切點註解:LogInfo.java
用來標記某個方法需要新增日誌。

/**
 * Description:
 * User: RoronoraZoro丶WangRui
 * Date: 2018-09-03
 * Time: 下午2:19
 */
@Target({ElementType.TYPE,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogInfo {

    /**
     * 描述
     * @return
     */
    public String description() default "";

    /**
     * 日誌型別
     * @return
     */
    public LogType logType();
}

需要被記錄日誌的某個Controller方法:

@ApiOperation("新增應用")
@Auth(allowRoles = {RoleType.ADMIN, RoleType.MASTER})
@PostMapping("/apps")
@LogInfo(logType = LogType.APP_ADD)
public GenericResult<AppDTO> add(@RequestBody @Valid AddAppRequest request) {
    return appService.add(request);
}

為其添加了@LogInfo(logType = LogType.APP_ADD)註解,標記為一個切點,表明了方法型別:LogType.APP_ADD 新增應用。
Log實體類:Log.java


/**
 * Description:
 * User: RoronoraZoro丶WangRui
 * Date: 2018-09-03
 * Time: 下午3:27
 */
@Data
public class Log extends BaseEntity{

    private int id;

    private int type;

    private String request = "{}";

    private String response = "{}";

    private String error = "{}";

}

LogService(及Impl),LogMapper(及xml)這些的程式碼就不貼了,直接看最關鍵的切面類:LogAop.java

import cn.hutool.core.util.ReflectUtil;
import com.mistra.api.aspect.annotaion.LogInfo;
import com.mistra.core.service.LogService;
import com.mistra.domain.entity.Log;
import lombok.extern.slf4j.Slf4j;
import net.sf.json.JSONObject;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;

/**
 * Description: 採集日誌
 * User: RoronoraZoro丶WangRui
 * Date: 2018-09-03
 * Time: 下午2:22
 */
@Aspect
@Component
@Slf4j
@Order(Integer.MIN_VALUE)
public class LogAop {

    @Autowired
    private LogService logService;

    private ThreadLocal<Log> threadLocal = new ThreadLocal<>();

    @Pointcut("@annotation(com.t4f.eunomia.api.aspect.annotaion.LogInfo)")
    public void controllerMethodPointcut() {
    }

    /**
     * 前置advice
     * @param point
     */
    @Before("controllerMethodPointcut()")
    public void before(JoinPoint point) {
        Log logEntity = new Log();
        //將當前實體儲存到threadLocal
        threadLocal.set(logEntity);
        //獲取連線點的方法簽名物件,在該物件中可以獲取到目標方法名,所屬類的Class等資訊
        MethodSignature signature = (MethodSignature) point.getSignature();
        //獲取到該方法@LogInfo註解中的日誌型別:列舉類LogType的值,儲存到log實體中
        logEntity.setType(signature.getMethod().getAnnotation(LogInfo.class).logType().value());

        //RequestContextHolder:持有上下文的Request容器,獲取到當前請求的request
        RequestAttributes ra = RequestContextHolder.getRequestAttributes();
        ServletRequestAttributes sra = (ServletRequestAttributes) ra;
        HttpServletRequest httpServletRequest = sra.getRequest();

        JSONObject jsonObject = new JSONObject();
        //儲存uri到json中
        jsonObject.accumulate("uri", httpServletRequest.getRequestURI().toString());

        //這一步獲取到的方法有可能是代理方法也有可能是真實方法
        Method m = ((MethodSignature) point.getSignature()).getMethod();
        //判斷代理物件本身是否是連線點所在的目標物件,不是的話就要通過反射重新獲取真實方法
        if (point.getThis().getClass() != point.getTarget().getClass()) {
            m = ReflectUtil.getMethod(point.getTarget().getClass(), m.getName(), m.getParameterTypes());
        }
        //通過真實方法獲取該方法的引數名稱
        LocalVariableTableParameterNameDiscoverer paramNames = new LocalVariableTableParameterNameDiscoverer();
        String[] parameterNames = paramNames.getParameterNames(m);

        //獲取連線點方法執行時的入參列表
        Object[] args = point.getArgs();
        //將引數名稱與入參值一一對應起來
        Map<String, Object> params = new HashMap<>();
        for (int i = 0; i < parameterNames.length; i++) {
            params.put(parameterNames[i], args[i]);
        }
        jsonObject.accumulate("params", params);
        //為log實體類的request欄位賦值
        logEntity.setRequest(jsonObject.toString());
        System.out.println("============================ 》Before : " + logEntity.toString());
    }

    /**
     * 方法成功return之後的advice
     * @param point
     * @param rtv
     */
    @AfterReturning(value = "controllerMethodPointcut()", returning = "rtv")
    public void after(JoinPoint point, Object rtv) {
        //得到當前執行緒的log物件
        Log log = threadLocal.get();
        //rtv為controller方法返回資料
        JSONObject jsonObject = JSONObject.fromObject(rtv);
        //為log實體的response欄位賦值
        log.setResponse(jsonObject.toString());
        //插入一條log資訊
        logService.add(threadLocal.get());
        //移除當前log實體
        threadLocal.remove();
        System.out.println("============================ 》AfterReturning : " + log.toString());
    }

    /**
     * 報錯之後的advice
     * @param throwing
     */
    @AfterThrowing(value = "controllerMethodPointcut()", throwing = "throwing")
    public void error(Throwable throwing) {
        Log log = threadLocal.get();
        try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
            //將報錯資訊寫入error欄位
            throwing.printStackTrace(new PrintStream(byteArrayOutputStream));
            log.setError(byteArrayOutputStream.toString());
        } catch (IOException e) {
            e.printStackTrace();
        }
        logService.add(threadLocal.get());
        threadLocal.remove();
        System.out.println("============================ 》AfterThrowing : " + log.toString());
    }
}

三、相關API

JoinPoint

AspectJ中的切入點匹配的執行點稱作連線的(Join Point),在通知方法中可以宣告一個JoinPoint型別的引數。通過JoinPoint可以訪問連線點的細節。下面簡要介紹JponPoint的方法:

1.java.lang.Object[] getArgs():獲取連線點方法執行時的入參列表;
2.Signature getSignature() :獲取連線點的方法簽名物件在該物件中可以獲取到目標方法名,所屬類的Class等資訊;
3.java.lang.Object getTarget() :獲取連線點所在的目標物件;
4.java.lang.Object getThis() :獲取代理物件本身;

RequestContextHolder

持有上下文的Request容器,RequestContextHolder裡面有兩個ThreadLocal儲存當前執行緒下的request。getRequestAttributes()方法,相當於直接獲取ThreadLocal裡面的值,這樣就保證了每一次獲取到的Request是該請求的request。
這裡寫圖片描述

JSONObject

public Object put (Object key, Object value) 將value對映到key下。如果此JSONObject物件之前存在一個value在這個key下,當前的value會替換掉之前的value。
public JSONObject accumulate (String key, Object value) 追加value到這個key下。這個方法同element()方法類似,特殊的是,如果當前已經存在一個value在這個key下,那麼一個JSONArray將會儲存在這個key下,來儲存所有追加的value。如果已經存在一個JSONArray,那麼當前的value就會新增到這個JSONArray中。
public JSONObject element (String key, Object value) 將鍵值對放到這個JSONObject物件裡面。如果當前value為空(null),那麼如果這個key存在的話,這個key就會移除掉。如果這個key之前有value值,那麼此方法會呼叫accumulate()方法。

這裡寫圖片描述