使用SpringAOP獲取用戶操作日志入庫
SpringAOP獲取用戶操作日志入庫
切service層中所有的方法,將有自定義註解的方法的操作日志入庫,其中需要註意的幾點:
- 註意aspectjweaver.jar包的版本,一般要1.6以上版本,否則會報錯
- 註意是否使用瞭雙重代理,spring.xml中不需要配置切面類的<bean>,否則會出現切兩次的情況
- 註意返回的數據類型,如果是實體類需要獲取實體類中每個屬性的值,若該實體類中的某個屬性也是實體類,需要再次循環獲取該屬性的實體類屬性
- 用遞歸的方法獲得參數及參數內容
package awb.aweb_soa.service.userOperationLog; import java.io.IOException; import java.lang.reflect.Method; import java.sql.Timestamp; import java.util.List; import javax.servlet.http.HttpServletRequest; import javax.sql.rowset.serial.SerialBlob; import org.apache.commons.lang.WordUtils; 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.Before; import org.aspectj.lang.annotation.Pointcut; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import cn.com.agree.aweb.asapi.ASAPI; import edm.aweb_soa.aweb_soa.base.user.UserOperationLogDO; import awb.aweb_soa.aservice.app.DefaultUser; import awb.aweb_soa.global.annotation.UserOperationType; @Service @Aspect public class UserOperationLogAspect { @Autowired UserOperationLog userOperationLog; /** * 業務邏輯方法切入點,切所有service層的方法 */ @Pointcut("execution(* awb.aweb_soa.service..*(..))") public void serviceCall() { } /** * 用戶登錄 */ @Pointcut("execution(* awb.aweb_soa.aservice.app.LoginController.signIn(..))") public void logInCall() { } /** * 退出登出切入點 */ @Pointcut("execution(* awb.aweb_soa.aservice.app.DefaultUser.logout(..))") public void logOutCall() { } /** * 操作日志(後置通知) * * @param joinPoint * @param rtv * @throws Throwable */ @AfterReturning(value = "serviceCall()", argNames = "rtv", returning = "rtv") public void doAfterReturning(JoinPoint joinPoint, Object rtv) throws Throwable { operationCall(joinPoint, rtv,"S"); } /** * 用戶登錄(後置通知) * * @param joinPoint * @param rtv * @throws Throwable */ @AfterReturning(value = "logInCall()", argNames = "rtv", returning = "rtv") public void doLoginReturning(JoinPoint joinPoint, Object rtv) throws Throwable { operationCall(joinPoint, rtv,"S"); } @Before(value = "logOutCall()") public void logoutCalls(JoinPoint joinPoint) throws Throwable { operationCall(joinPoint, null,"S"); } /** * 操作日志(異常通知) * * @param joinPoint * @param e * @throws Throwable */ @AfterThrowing(value = "serviceCall()", throwing="e") public void doAfterThrowing(JoinPoint joinPoint, Object e) throws Throwable { operationCall(joinPoint, e,"F"); } /** * 獲取用戶操作日志詳細信息 * * @param joinPoint * @param rtv * @param status * @throws Throwable */ private void operationCall(JoinPoint joinPoint, Object rtv,String status) throws Throwable { //獲取當前用戶 DefaultUser currentUser = (DefaultUser) ASAPI.authenticator().getCurrentUser(); String userName = null; if (currentUser != null) { //獲取用戶名 userName = currentUser.getUsername(); //獲取用戶ip地址 HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder .getRequestAttributes()).getRequest(); String userIp = getIpAddress(request); // 拼接操作內容的字符串 StringBuffer rs = new StringBuffer(); // 獲取類名 String className = joinPoint.getTarget().getClass() .getCanonicalName(); rs.append("類名:" + className + "; </br>"); // 獲取方法名 String methodName = joinPoint.getSignature().getName(); rs.append("方法名:" + methodName + "; </br>"); // 獲取類的所有方法 Method[] methods = joinPoint.getTarget().getClass() .getDeclaredMethods(); //創建變量用於存儲註解返回的value值 String operationType = ""; for (Method method:methods) { String mName = method.getName(); // 當切的方法和類中的方法相同時 if (methodName.equals(mName)) { //獲取方法的UserOperationType註解 UserOperationType userOperationType = method.getAnnotation(UserOperationType.class); //如果方法存在UserOperationType註解時 if (userOperationType!=null) { //獲取註解的value值 operationType = userOperationType.value(); // 獲取操作內容 Object[] args = joinPoint.getArgs(); int i = 1; if (args!=null&&args.length>0) { for (Object arg :args) { rs.append("[參數" + i + "======"); userOptionContent(arg, rs); rs.append("]</br>"); } } // 創建日志對象 UserOperationLogDO log = new UserOperationLogDO(); log.setLogId(ASAPI.randomizer().getRandomGUID()); log.setUserCode(userName); log.setUserIP(userIp); log.setOperationDesc(new SerialBlob(rs.toString().getBytes("UTF-8"))); log.setOperationType(operationType); log.setOperationTime(new Timestamp(System.currentTimeMillis())); log.setStatus(status); //日志對象入庫 userOperationLog.insertLog(log); } } } } } /** * 獲取請求主機IP地址,如果通過代理進來,則透過防火墻獲取真實IP地址; * * @param request * @return * @throws IOException */ public final static String getIpAddress(HttpServletRequest request) throws IOException { // 獲取請求主機IP地址,如果通過代理進來,則透過防火墻獲取真實IP地址 String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_CLIENT_IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_X_FORWARDED_FOR"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } } else if (ip.length() > 15) { String[] ips = ip.split(","); for (int index = 0; index < ips.length; index++) { String strIp = (String) ips[index]; if (!("unknown".equalsIgnoreCase(strIp))) { ip = strIp; break; } } } return ip; } /** * 使用Java反射來獲取被攔截方法(insert、update, delete)的參數值, 將參數值拼接為操作內容 */ @SuppressWarnings("unchecked") public StringBuffer userOptionContent(Object info, StringBuffer rs){ String className = null; // 獲取參數對象類型 className = info.getClass().getName(); className = className.substring(className.lastIndexOf(".") + 1); rs.append("類型:"+className+","); //參數對象類型不是實體類或者集合時,直接顯示參數值 if (className.equals("String")||className.equals("int")||className.equals("Date") ||className.equals("Timestamp")||className.equals("Integer") ||className.equals("B")||className.equals("Long")) { rs.append("值:(" + info + ")"); } //參數類型是ArrayList集合,迭代裡面的對象,並且遞歸 if(className.equals("ArrayList")){ int i = 1; //將參數對象轉換成List集合 List<Object> list = (List<Object>) info; for (Object obj: list) { rs.append("</br> 集合內容" + i + "————"); //遞歸 userOptionContent(obj, rs); rs.append("</br>"); i++; } //參數對象是實體類 }else{ // 獲取對象的所有方法 Method[] methods = info.getClass().getDeclaredMethods(); //遍歷對象中的所有方法是否是get方法 for (Method method : methods) { //獲取方法名字 String methodName = method.getName(); if (methodName.indexOf("get") == -1 || methodName.equals("getPassword") || methodName.equals("getBytes")|| methodName.equals("getChars") || methodName.equals("getLong") || methodName.equals("getInteger") || methodName.equals("getTime") || methodName.equals("getCalendarDate") || methodName.equals("getDay") || methodName.equals("getMinutes") || methodName.equals("getHours")|| methodName.equals("getSeconds") || methodName.equals("getYear") || methodName.equals("getTimezoneOffset") || methodName.equals("getDate") || methodName.equals("getJulianCalendar") || methodName.equals("getMillisOf") || methodName.equals("getCalendarSystem") || methodName.equals("getMonth")|| methodName.equals("getTimeImpl") || methodName.equals("getNanos")) { continue; } rs.append("</br> " + className + "——" + changeString(methodName) + ":"); Object rsValue = null; try { // 調用get方法,獲取返回值 rsValue = method.invoke(info); userOptionContent(rsValue, rs); } catch (Exception e) { continue; } } } return rs; } //有get方法獲得屬性名 public String changeString(String name){ name = name.substring(3); name = WordUtils.uncapitalize(name);//首字符小寫 return name; } }
記錄操作日志的一般套路
記錄操作日志是web系統做安全審計和系統維護的重要手段,這裡總結筆者在用java和python開發web系統過程中總結出來的、具有普遍意義的方法。
在java體系下,網絡上搜索瞭一下,幾乎一邊倒的做法是用AOP,通過註解的方式記錄操作日志,在此,筆者並不是很認同這種做法,原因如下:
- AOP的應用場景是各種接口中可以抽象出普遍的行為,且切入點選擇需要在各接口中比較統一。
- 記錄審計日志除瞭ip、用戶等共同的信息外,還需要記錄很多個性化的東西,比如一次修改操作,一般來講需要記錄對象標識、修改前後的值等等。有的值甚至並不能從request參數中直接獲取,有可能需要一定的邏輯判斷或者運算,使用AOP並不合適。
- 當然,有人說AOP中也可以傳遞參數,這裡且不說有些日志信息需要從request參數計算而來的問題,就是是可以直接獲取,在註解中傳遞一大堆的參數也失去瞭AOP簡單的好處。
當然這主要還是看需求,如果你的操作日志僅僅是需要記錄ip、用戶等與具體接口無關的信息,那就無所謂。
接下來記錄操作日志就比較簡單瞭,無非就是在接口返回之前記錄一些操作信息,這些信息可能從request參數中獲取,也可能用request參數經過一些運算獲取,都無所謂,但是有一點需要註意,你得確保成功或者失敗場景都有記錄。
那麼問題來瞭,現在的web框架,REST接口調用失敗普遍的做法是業務往外拋異常,由一個“統一異常處理”模塊來處理異常並構造返回體,Java的String Boot(ExceptionHandler)、Python的flask(裝飾器裡make_response)、pecan(hook)等莫不是如此。那麼接口調用失敗的時候如何記錄審計日志呢?肯定不可能在業務每個拋異常的地方去記錄,這太麻煩,解決方法當然是在前面說的這個“統一異常處理”模塊去處理,那麼記錄的參數如何傳遞給這個模塊呢?方法就是放在本地線程相關的變量裡,java接口可以在入口處整理操作日志信息存放在ThreadLocal變量裡,成功或者失敗的時候設置一個status然後記錄入庫即可;python下,flask接口可以放在app_context的g裡,pecan可以放在session裡。另外如果是異步任務,還需要給任務寫個回調來更新狀態。
可見,不管是用java還是python開發操作日志,都是相同的套路,總結如下圖:
還有一點要註意,如果java接口是用的@Valid註解來進行參數校驗,那麼在校驗失敗時會拋出MethodArgumentNotValidException,問題在於,這個Valid發生在請求進入接口之前,也就是說,出現參數校驗失敗拋出MethodArgumentNotValidException的時候還沒有進入接口裡面的代碼,自然也就沒有往本地線程中記錄操作日志需要的信息,那怎麼辦呢?方法就是在接口的請求入參中加一個BindingResult binding類型的參數,這個參數會截獲參數校驗的接口而不是拋出異常,然後在代碼中(已經往線程上下文中寫入瞭操作日志需要的信息以後的代碼中)判斷當binding中有錯誤,就拋出MethodArgumentNotValidException,此時就可以獲取到操作日志需要的信息瞭,代碼如下:
// 先往threadlocal變量中存入操作日志需要的信息
…
以上為個人經驗,希望能給大傢一個參考,也希望大傢多多支持WalkonNet。
推薦閱讀:
- 基於SpringAop中JoinPoint對象的使用說明
- Java反射機制如何解決數據傳值為空的問題
- Java Redisson多策略註解限流
- Spring AOP實現復雜的日志記錄操作(自定義註解)
- SpringBoot @Cacheable自定義KeyGenerator方式