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
可以在方法執行前,執行時,執行後切入執行一段公共程式碼,非常適合用於公共邏輯處理。 -
自定義註解,宣告一種行為,使配置簡化,程式碼層面更加簡潔。