1. 程式人生 > >Spring aop+自定義註解統一記錄使用者行為日誌

Spring aop+自定義註解統一記錄使用者行為日誌

Spring aop+自定義註解統一記錄使用者行為日誌

原創: zhangshaolin 張少林同學 今天

寫在前面

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

場景

我們通常有這樣一個需求:列印後臺介面請求的具體引數,列印介面請求的最終響應結果,以及記錄哪個使用者在什麼時間點,訪問了哪些介面,介面響應耗時多長時間等等。這樣做的目的是為了記錄使用者的訪問行為,同時便於跟蹤介面呼叫情況,以便於出現問題時能夠快速定位問題所在。

最簡單的做法是這樣的:

1    @GetMapping(value = "/info")
2    public BaseResult userInfo() {
3        //1.列印介面入參日誌資訊,標記介面訪問時間戳
4        BaseResult result = mUserService.userInfo();
5        //2.列印/入庫 介面響應資訊,響應時間等
6        return result;
7    }

這種做法沒毛病,但是稍微比較敏感的同學就會發覺有以下缺點:

  • 每個介面都充斥著重複的程式碼,有沒有辦法提取這部分程式碼,做到統一管理呢?答案是使用Spring aop 面向切面執行這段公共程式碼。

  • 充斥著 硬編碼 的味道,有些場景會要求在介面響應結束後,列印日誌資訊,儲存到資料庫,甚至要把日誌記錄到elk日誌系統等待,同時這些操作要做到可控,有沒有什麼操作可以直接宣告即可?答案是使用自定義註解,宣告式的處理訪問日誌。

自定義註解

新增日誌註解類,註解作用於方法級別,執行時起作用。

 [email protected]
({ElementType.METHOD}) //註解作用於方法級別 [email protected](RetentionPolicy.RUNTIME) //執行時起作用 3public @interface Loggable { 4 5    /** 6     * 是否輸出日誌 7     */ 8    boolean loggable() default true; 9 10    /** 11     * 日誌資訊描述,可以記錄該方法的作用等資訊。 12     */ 13    String descp() default ""; 14 15    /** 16     * 日誌型別,可能存在多種介面型別都需要記錄日誌,比如dubbo介面,web介面 17     */ 18    LogTypeEnum type() default LogTypeEnum.WEB; 19 20    /** 21     * 日誌等級 22     */ 23    String level() default "INFO"; 24 25    /** 26     * 日誌輸出範圍,用於標記需要記錄的日誌資訊範圍,包含入參、返回值等。 27     * ALL-入參和出參, BEFORE-入參, AFTER-出參 28     */ 29    LogScopeEnum scope() default LogScopeEnum.ALL; 30 31    /** 32     * 入參輸出範圍,值為入參變數名,多個則逗號分割。不為空時,入參日誌僅列印include中的變數 33     */ 34    String include() default ""; 35 36    /** 37     * 是否存入資料庫 38     */ 39    boolean db() default true; 40 41    /** 42     * 是否輸出到控制檯 43     * 44     * @return 45     */ 46    boolean console() default true; 47}

日誌型別列舉類:

 1public enum LogTypeEnum {
 2
 3    WEB("-1"), DUBBO("1"), MQ("2");
 4
 5    private final String value;
 6
 7    LogTypeEnum(String value) {
 8        this.value = value;
 9    }
10
11    public String value() {
12        return this.value;
13    }
14}

日誌作用範圍列舉類:

 1public enum LogScopeEnum {
 2
 3    ALL, BEFORE, AFTER;
 4
 5    public boolean contains(LogScopeEnum scope) {
 6        if (this == ALL) {
 7            return true;
 8        } else {
 9            return this == scope;
10        }
11    }
12
13    @Override
14    public String toString() {
15        String str = "";
16        switch (this) {
17            case ALL:
18                break;
19            case BEFORE:
20                str = "REQUEST";
21                break;
22            case AFTER:
23                str = "RESPONSE";
24                break;
25            default:
26                break;
27        }
28        return str;
29    }
30}

相關說明已在程式碼中註釋,這裡不再說明。

使用 Spring aop 重構

引入依賴:

 1    <dependency>
 2            <groupId>org.aspectj</groupId>
 3            <artifactId>aspectjweaver</artifactId>
 4            <version>1.8.8</version>
 5        </dependency>
 6        <dependency>
 7            <groupId>org.aspectj</groupId>
 8            <artifactId>aspectjrt</artifactId>
 9            <version>1.8.13</version>
10        </dependency>
11        <dependency>
12            <groupId>org.javassist</groupId>
13            <artifactId>javassist</artifactId>
14            <version>3.22.0-GA</version>
15    </dependency>

配置檔案啟動aop註解,基於類的代理,並且在 spring 中注入 aop 實現類。

 1<?xml version="1.0" encoding="UTF-8"?>
 2<beans xmlns="http://www.springframework.org/schema/beans"
 3    .....省略部分程式碼">
 4
 5    <!-- 掃描controller -->
 6    <context:component-scan base-package="**.*controller"/>
 7    <context:annotation-config/>
 8
 9    <!-- 啟動aop註解基於類的代理(這時需要cglib庫),如果proxy-target-class屬值被設定為false或者這個屬性被省略,那麼標準的JDK 基於介面的代理將起作用 -->
10    <aop:config proxy-target-class="true"/>
11
12     <!-- web層日誌記錄AOP實現 -->
13    <bean class="com.easywits.common.aspect.WebLogAspect"/>
14</beans>
15

新增 WebLogAspect 類實現

  1/**
  2 * 日誌記錄AOP實現
  3 * create by zhangshaolin on 2018/5/1
  4 */
  [email protected]
  [email protected]
  7public class WebLogAspect {
  8
  9    private static final Logger LOGGER = LoggerFactory.getLogger(WebLogAspect.class);
 10
 11    // 開始時間
 12    private long startTime = 0L;
 13
 14    // 結束時間
 15    private long endTime = 0L;
 16
 17    /**
 18     * Controller層切點
 19     */
 20    @Pointcut("execution(* *..controller..*.*(..))")
 21    public void controllerAspect() {
 22    }
 23
 24    /**
 25     * 前置通知 用於攔截Controller層記錄使用者的操作
 26     *
 27     * @param joinPoint 切點
 28     */
 29    @Before("controllerAspect()")
 30    public void doBeforeInServiceLayer(JoinPoint joinPoint) {
 31    }
 32
 33    /**
 34     * 配置controller環繞通知,使用在方法aspect()上註冊的切入點
 35     *
 36     * @param point 切點
 37     * @return
 38     * @throws Throwable
 39     */
 40    @Around("controllerAspect()")
 41    public Object doAround(ProceedingJoinPoint point) throws Throwable {
 42        // 獲取request
 43        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
 44        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
 45        HttpServletRequest request = servletRequestAttributes.getRequest();
 46
 47        //目標方法實體
 48        Method method = ((MethodSignature) point.getSignature()).getMethod();
 49        boolean hasMethodLogAnno = method
 50                .isAnnotationPresent(Loggable.class);
 51        //沒加註解 直接執行返回結果
 52        if (!hasMethodLogAnno) {
 53            return point.proceed();
 54        }
 55
 56        //日誌列印外部開關預設關閉
 57        String logSwitch = StringUtils.equals(RedisUtil.get(BaseConstants.CACHE_WEB_LOG_SWITCH), BaseConstants.YES) ? BaseConstants.YES : BaseConstants.NO;
 58
 59        //記錄日誌資訊
 60        LogMessage logMessage = new LogMessage();
 61
 62        //方法註解實體
 63        Loggable methodLogAnnon = method.getAnnotation(Loggable.class);
 64
 65        //處理入參日誌
 66        handleRequstLog(point, methodLogAnnon, request, logMessage, logSwitch);
 67
 68        //執行目標方法內容,獲取執行結果
 69        Object result = point.proceed();
 70
 71        //處理介面響應日誌
 72        handleResponseLog(logSwitch, logMessage, methodLogAnnon, result);
 73        return result;
 74    }
 75
 76    /**
 77     * 處理入參日誌
 78     *
 79     * @param point           切點
 80     * @param methodLogAnnon  日誌註解
 81     * @param logMessage      日誌資訊記錄實體
 82     */
 83    private void handleRequstLog(ProceedingJoinPoint point, Loggable methodLogAnnon, HttpServletRequest request,
 84                                 LogMessage logMessage, String logSwitch) throws Exception {
 85
 86        String paramsText = "";
 87        //引數列表
 88        String includeParam = methodLogAnnon.include();
 89        Map<String, Object> methodParamNames = getMethodParamNames(
 90                point.getTarget().getClass(), point.getSignature().getName(), includeParam);
 91        Map<String, Object> params = getArgsMap(
 92                point, methodParamNames);
 93        if (params != null) {
 94            //序列化引數列表
 95            paramsText = JSON.toJSONString(params);
 96        }
 97        logMessage.setParameter(paramsText);
 98        //判斷是否輸出日誌
 99        if (methodLogAnnon.loggable()
100                && methodLogAnnon.scope().contains(LogScopeEnum.BEFORE)
101                && methodLogAnnon.console()
102                && StringUtils.equals(logSwitch, BaseConstants.YES)) {
103            //列印入參日誌
104            LOGGER.info("【{}】 介面入參成功!, 方法名稱:【{}】, 請求引數:【{}】", methodLogAnnon.descp().toString(), point.getSignature().getName(), paramsText);
105        }
106        startTime = System.currentTimeMillis();
107        //介面描述
108        logMessage.setDescription(methodLogAnnon.descp().toString());
109
110        //...省略部分構造logMessage資訊程式碼
111    }
112
113    /**
114     * 處理響應日誌
115     *
116     * @param logSwitch         外部日誌開關,用於外部動態開啟日誌列印
117     * @param logMessage        日誌記錄資訊實體
118     * @param methodLogAnnon    日誌註解實體
119     * @param result           介面執行結果
120     */
121    private void handleResponseLog(String logSwitch, LogMessage logMessage, Loggable methodLogAnnon, Object result) {
122        endTime = System.currentTimeMillis();
123        //結束時間
124        logMessage.setEndTime(DateUtils.getNowDate());
125        //消耗時間
126        logMessage.setSpendTime(endTime - startTime);
127        //是否輸出日誌
128        if (methodLogAnnon.loggable()
129                && methodLogAnnon.scope().contains(LogScopeEnum.AFTER)) {
130            //判斷是否入庫
131            if (methodLogAnnon.db()) {
132                //...省略入庫程式碼
133            }
134            //判斷是否輸出到控制檯
135            if (methodLogAnnon.console() 
136                    && StringUtils.equals(logSwitch, BaseConstants.YES)) {
137                //...省略列印日誌程式碼
138            }
139        }
140    }
141    /**
142     * 獲取方法入參變數名
143     *
144     * @param cls        觸發的類
145     * @param methodName 觸發的方法名
146     * @param include    需要列印的變數名
147     * @return
148     * @throws Exception
149     */
150    private Map<String, Object> getMethodParamNames(Class cls,
151                                                    String methodName, String include) throws Exception {
152        ClassPool pool = ClassPool.getDefault();
153        pool.insertClassPath(new ClassClassPath(cls));
154        CtMethod cm = pool.get(cls.getName()).getDeclaredMethod(methodName);
155        LocalVariableAttribute attr = (LocalVariableAttribute) cm
156                .getMethodInfo().getCodeAttribute()
157                .getAttribute(LocalVariableAttribute.tag);
158
159        if (attr == null) {
160            throw new Exception("attr is null");
161        } else {
162            Map<String, Object> paramNames = new HashMap<>();
163            int paramNamesLen = cm.getParameterTypes().length;
164            int pos = Modifier.isStatic(cm.getModifiers()) ? 0 : 1;
165            if (StringUtils.isEmpty(include)) {
166                for (int i = 0; i < paramNamesLen; i++) {
167                    paramNames.put(attr.variableName(i + pos), i);
168                }
169            } else { // 若include不為空
170                for (int i = 0; i < paramNamesLen; i++) {
171                    String paramName = attr.variableName(i + pos);
172                    if (include.indexOf(paramName) > -1) {
173                        paramNames.put(paramName, i);
174                    }
175                }
176            }
177            return paramNames;
178        }
179    }
180
181    /**
182     * 組裝入參Map
183     *
184     * @param point       切點
185     * @param methodParamNames 引數名稱集合
186     * @return
187     */
188    private Map getArgsMap(ProceedingJoinPoint point,
189                           Map<String, Object> methodParamNames) {
190        Object[] args = point.getArgs();
191        if (null == methodParamNames) {
192            return Collections.EMPTY_MAP;
193        }
194        for (Map.Entry<String, Object> entry : methodParamNames.entrySet()) {
195            int index = Integer.valueOf(String.valueOf(entry.getValue()));
196            if (args != null && args.length > 0) {
197                Object arg = (null == args[index] ? "" : args[index]);
198                methodParamNames.put(entry.getKey(), arg);
199            }
200        }
201        return methodParamNames;
202    }
203}

使用註解的方式處理介面日誌

介面改造如下:

1    @Loggable(descp = "使用者個人資料", include = "")
2    @GetMapping(value = "/info")
3    public BaseResult userInfo() {
4        return mUserService.userInfo();
5    }

可以看到,只添加了註解@Loggable,所有的web層介面只需要新增@Loggable註解就能實現日誌處理了,方便簡潔!最終效果如下:

訪問入參,響應日誌資訊:

使用者行為日誌入庫部分資訊:

簡單總結

  • 編寫程式碼時,看到重複性程式碼應當立即重構,杜絕重複程式碼。

  • Spring aop 可以在方法執行前,執行時,執行後切入執行一段公共程式碼,非常適合用於公共邏輯處理。

  • 自定義註解,宣告一種行為,使配置簡化,程式碼層面更加簡潔。