Spring aop+自定義註解統一記錄用戶行爲日誌

寫在前面

本文不涉及過多的Spring aop基本概念以及基本用法介紹,以實際場景使用爲主。java

場景

咱們一般有這樣一個需求:打印後臺接口請求的具體參數,打印接口請求的最終響應結果,以及記錄哪一個用戶在什麼時間點,訪問了哪些接口,接口響應耗時多長時間等等。這樣作的目的是爲了記錄用戶的訪問行爲,同時便於跟蹤接口調用狀況,以便於出現問題時可以快速定位問題所在。web

最簡單的作法是這樣的:spring

@GetMapping(value = "/info")
    public BaseResult userInfo() {
        //1.打印接口入參日誌信息,標記接口訪問時間戳
        BaseResult result = mUserService.userInfo();
        //2.打印/入庫 接口響應信息,響應時間等
        return result;
    }

這種作法沒毛病,可是稍微比較敏感的同窗就會發覺有如下缺點:數據庫

  • 每一個接口都充斥着重複的代碼,有沒有辦法提取這部分代碼,作到統一管理呢?答案是使用 Spring aop 面向切面執行這段公共代碼。
  • 充斥着 硬編碼 的味道,有些場景會要求在接口響應結束後,打印日誌信息,保存到數據庫,甚至要把日誌記錄到elk日誌系統等待,同時這些操做要作到可控,有沒有什麼操做能夠直接聲明便可?答案是使用自定義註解,聲明式的處理訪問日誌。

自定義註解

新增日誌註解類,註解做用於方法級別,運行時起做用。app

@Target({ElementType.METHOD}) //註解做用於方法級別
@Retention(RetentionPolicy.RUNTIME) //運行時起做用
public @interface Loggable {

    /**
     * 是否輸出日誌
     */
    boolean loggable() default true;

    /**
     * 日誌信息描述,能夠記錄該方法的做用等信息。
     */
    String descp() default "";

    /**
     * 日誌類型,可能存在多種接口類型都須要記錄日誌,好比dubbo接口,web接口
     */
    LogTypeEnum type() default LogTypeEnum.WEB;

    /**
     * 日誌等級
     */
    String level() default "INFO";

    /**
     * 日誌輸出範圍,用於標記須要記錄的日誌信息範圍,包含入參、返回值等。
     * ALL-入參和出參, BEFORE-入參, AFTER-出參
     */
    LogScopeEnum scope() default LogScopeEnum.ALL;

    /**
     * 入參輸出範圍,值爲入參變量名,多個則逗號分割。不爲空時,入參日誌僅打印include中的變量
     */
    String include() default "";

    /**
     * 是否存入數據庫
     */
    boolean db() default true;

    /**
     * 是否輸出到控制檯
     *
     * @return
     */
    boolean console() default true;
}

日誌類型枚舉類:ide

public enum LogTypeEnum {

    WEB("-1"), DUBBO("1"), MQ("2");

    private final String value;

    LogTypeEnum(String value) {
        this.value = value;
    }

    public String value() {
        return this.value;
    }
}

日誌做用範圍枚舉類:this

public enum LogScopeEnum {

    ALL, BEFORE, AFTER;

    public boolean contains(LogScopeEnum scope) {
        if (this == ALL) {
            return true;
        } else {
            return this == scope;
        }
    }

    @Override
    public String toString() {
        String str = "";
        switch (this) {
            case ALL:
                break;
            case BEFORE:
                str = "REQUEST";
                break;
            case AFTER:
                str = "RESPONSE";
                break;
            default:
                break;
        }
        return str;
    }
}

相關說明已在代碼中註釋,這裏再也不說明。編碼

使用 Spring aop 重構

引入依賴:spa

<dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.8.8</version>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjrt</artifactId>
            <version>1.8.13</version>
        </dependency>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.22.0-GA</version>
    </dependency>

配置文件啓動aop註解,基於類的代理,而且在 spring 中注入 aop 實現類。代理

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    .....省略部分代碼">

    <!-- 掃描controller -->
    <context:component-scan base-package="**.*controller"/>
    <context:annotation-config/>

    <!-- 啓動aop註解基於類的代理(這時須要cglib庫),若是proxy-target-class屬值被設置爲false或者這個屬性被省略,那麼標準的JDK 基於接口的代理將起做用 -->
    <aop:config proxy-target-class="true"/>
    
     <!-- web層日誌記錄AOP實現 -->
    <bean class="com.easywits.common.aspect.WebLogAspect"/>
</beans>

新增 WebLogAspect 類實現

/**
 * 日誌記錄AOP實現
 * create by zhangshaolin on 2018/5/1
 */
@Aspect
@Component
public class WebLogAspect {

    private static final Logger LOGGER = LoggerFactory.getLogger(WebLogAspect.class);

    // 開始時間
    private long startTime = 0L;

    // 結束時間
    private long endTime = 0L;

    /**
     * Controller層切點
     */
    @Pointcut("execution(* *..controller..*.*(..))")
    public void controllerAspect() {
    }

    /**
     * 前置通知 用於攔截Controller層記錄用戶的操做
     *
     * @param joinPoint 切點
     */
    @Before("controllerAspect()")
    public void doBeforeInServiceLayer(JoinPoint joinPoint) {
    }

    /**
     * 配置controller環繞通知,使用在方法aspect()上註冊的切入點
     *
     * @param point 切點
     * @return
     * @throws Throwable
     */
    @Around("controllerAspect()")
    public Object doAround(ProceedingJoinPoint point) throws Throwable {
        // 獲取request
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
        HttpServletRequest request = servletRequestAttributes.getRequest();

        //目標方法實體
        Method method = ((MethodSignature) point.getSignature()).getMethod();
        boolean hasMethodLogAnno = method
                .isAnnotationPresent(Loggable.class);
        //沒加註解 直接執行返回結果
        if (!hasMethodLogAnno) {
            return point.proceed();
        }

        //日誌打印外部開關默認關閉
        String logSwitch = StringUtils.equals(RedisUtil.get(BaseConstants.CACHE_WEB_LOG_SWITCH), BaseConstants.YES) ? BaseConstants.YES : BaseConstants.NO;

        //記錄日誌信息
        LogMessage logMessage = new LogMessage();

        //方法註解實體
        Loggable methodLogAnnon = method.getAnnotation(Loggable.class);
        
        //處理入參日誌
        handleRequstLog(point, methodLogAnnon, request, logMessage, logSwitch);
        
        //執行目標方法內容,獲取執行結果
        Object result = point.proceed();
        
        //處理接口響應日誌
        handleResponseLog(logSwitch, logMessage, methodLogAnnon, result);
        return result;
    }
    
    /**
     * 處理入參日誌
     *
     * @param point           切點
     * @param methodLogAnnon  日誌註解
     * @param logMessage      日誌信息記錄實體
     */
    private void handleRequstLog(ProceedingJoinPoint point, Loggable methodLogAnnon, HttpServletRequest request,
                                 LogMessage logMessage, String logSwitch) throws Exception {

        String paramsText = "";
        //參數列表
        String includeParam = methodLogAnnon.include();
        Map<String, Object> methodParamNames = getMethodParamNames(
                point.getTarget().getClass(), point.getSignature().getName(), includeParam);
        Map<String, Object> params = getArgsMap(
                point, methodParamNames);
        if (params != null) {
            //序列化參數列表
            paramsText = JSON.toJSONString(params);
        }
        logMessage.setParameter(paramsText);
        //判斷是否輸出日誌
        if (methodLogAnnon.loggable()
                && methodLogAnnon.scope().contains(LogScopeEnum.BEFORE)
                && methodLogAnnon.console()
                && StringUtils.equals(logSwitch, BaseConstants.YES)) {
            //打印入參日誌
            LOGGER.info("【{}】 接口入參成功!, 方法名稱:【{}】, 請求參數:【{}】", methodLogAnnon.descp().toString(), point.getSignature().getName(), paramsText);
        }
        startTime = System.currentTimeMillis();
        //接口描述
        logMessage.setDescription(methodLogAnnon.descp().toString());
        
        //...省略部分構造logMessage信息代碼
    }

    /**
     * 處理響應日誌
     *
     * @param logSwitch         外部日誌開關,用於外部動態開啓日誌打印
     * @param logMessage        日誌記錄信息實體
     * @param methodLogAnnon    日誌註解實體
     * @param result           接口執行結果
     */
    private void handleResponseLog(String logSwitch, LogMessage logMessage, Loggable methodLogAnnon, Object result) {
        endTime = System.currentTimeMillis();
        //結束時間
        logMessage.setEndTime(DateUtils.getNowDate());
        //消耗時間
        logMessage.setSpendTime(endTime - startTime);
        //是否輸出日誌
        if (methodLogAnnon.loggable()
                && methodLogAnnon.scope().contains(LogScopeEnum.AFTER)) {
            //判斷是否入庫
            if (methodLogAnnon.db()) {
                //...省略入庫代碼
            }
            //判斷是否輸出到控制檯
            if (methodLogAnnon.console() 
                    && StringUtils.equals(logSwitch, BaseConstants.YES)) {
                //...省略打印日誌代碼
            }
        }
    }
    /**
     * 獲取方法入參變量名
     *
     * @param cls        觸發的類
     * @param methodName 觸發的方法名
     * @param include    須要打印的變量名
     * @return
     * @throws Exception
     */
    private Map<String, Object> getMethodParamNames(Class cls,
                                                    String methodName, String include) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath(new ClassClassPath(cls));
        CtMethod cm = pool.get(cls.getName()).getDeclaredMethod(methodName);
        LocalVariableAttribute attr = (LocalVariableAttribute) cm
                .getMethodInfo().getCodeAttribute()
                .getAttribute(LocalVariableAttribute.tag);

        if (attr == null) {
            throw new Exception("attr is null");
        } else {
            Map<String, Object> paramNames = new HashMap<>();
            int paramNamesLen = cm.getParameterTypes().length;
            int pos = Modifier.isStatic(cm.getModifiers()) ? 0 : 1;
            if (StringUtils.isEmpty(include)) {
                for (int i = 0; i < paramNamesLen; i++) {
                    paramNames.put(attr.variableName(i + pos), i);
                }
            } else { // 若include不爲空
                for (int i = 0; i < paramNamesLen; i++) {
                    String paramName = attr.variableName(i + pos);
                    if (include.indexOf(paramName) > -1) {
                        paramNames.put(paramName, i);
                    }
                }
            }
            return paramNames;
        }
    }

    /**
     * 組裝入參Map
     *
     * @param point       切點
     * @param methodParamNames 參數名稱集合
     * @return
     */
    private Map getArgsMap(ProceedingJoinPoint point,
                           Map<String, Object> methodParamNames) {
        Object[] args = point.getArgs();
        if (null == methodParamNames) {
            return Collections.EMPTY_MAP;
        }
        for (Map.Entry<String, Object> entry : methodParamNames.entrySet()) {
            int index = Integer.valueOf(String.valueOf(entry.getValue()));
            if (args != null && args.length > 0) {
                Object arg = (null == args[index] ? "" : args[index]);
                methodParamNames.put(entry.getKey(), arg);
            }
        }
        return methodParamNames;
    }
}

使用註解的方式處理接口日誌

接口改造以下:

@Loggable(descp = "用戶我的資料", include = "")
    @GetMapping(value = "/info")
    public BaseResult userInfo() {
        return mUserService.userInfo();
    }

能夠看到,只添加了註解@Loggable,全部的web層接口只須要添加@Loggable註解就能實現日誌處理了,方便簡潔!最終效果以下:

訪問入參,響應日誌信息:

用戶行爲日誌入庫部分信息:

簡單總結

  • 編寫代碼時,看到重複性代碼應當當即重構,杜絕重複代碼。
  • Spring aop 能夠在方法執行前,執行時,執行後切入執行一段公共代碼,很是適合用於公共邏輯處理。
  • 自定義註解,聲明一種行爲,使配置簡化,代碼層面更加簡潔。

最後

更多原創文章會第一時間推送公衆號【張少林同窗】,歡迎關注!

相關文章
相關標籤/搜索