1. 前言
有一段時間沒有使用面向切面編程(aop),有點生疏。恰好項目有在執行方法前後需要預處理和返回處理的需求,所以打算藉機重拾起來。由於保密的原因,加上aop的原理大同小異,我還是從萬年不變的日誌記錄開始aop的複習吧~
2. 準備工作
2.1.表格創建
2.1.1操作日誌表
CREATE TABLE `operation_log` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵id',
`user_id` varchar(64) DEFAULT NULL COMMENT '用戶id',
`user_name` varchar(100) DEFAULT NULL COMMENT '用戶名',
`type` varchar(64) DEFAULT NULL COMMENT '請求類型',
`module` varchar(100) DEFAULT NULL COMMENT '請求模塊',
`request_des` varchar(500) DEFAULT NULL COMMENT '請求描述',
`method_name` varchar(255) DEFAULT NULL COMMENT '請求方法名',
`ip` varchar(20) DEFAULT NULL COMMENT '請求ip',
`url` varchar(255) DEFAULT NULL COMMENT '請求地址',
`request_param` varchar(255) DEFAULT NULL COMMENT '請求參數',
`return_data` varchar(255) DEFAULT NULL COMMENT '返回值',
`start_time` varchar(50) DEFAULT NULL COMMENT '請求開始時間',
`finish_time` varchar(50) DEFAULT NULL COMMENT '請求完成時間',
`return_time` varchar(50) DEFAULT NULL COMMENT '接口返回時間',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8;
2.1.2異常日誌表
CREATE TABLE `exception_log` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵id',
`user_id` varchar(64) DEFAULT NULL COMMENT '用戶id',
`user_name` varchar(100) DEFAULT NULL COMMENT '用戶名',
`exception_name` varchar(255) DEFAULT NULL COMMENT '異常名稱',
`exception_msg` varchar(6000) DEFAULT NULL COMMENT '異常描述',
`method_name` varchar(255) DEFAULT NULL COMMENT '請求方法名',
`ip` varchar(20) DEFAULT NULL COMMENT '請求ip',
`url` varchar(255) DEFAULT NULL COMMENT '請求地址',
`request_param` varchar(255) DEFAULT NULL COMMENT '請求參數',
`create_time` varchar(50) DEFAULT NULL COMMENT '異常產生時間',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
對應的entity、service和mapper在這裏省略不寫了,可以直接代碼生成。
另外IpAdressUtil是用於獲取ip地址,這裏就不提供了。
3. 實現日誌切面
3.1 導入依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
3.2 創建操作日誌註解類
可以在切面類中用切入點表達式指定使用的類或方法,也可以創建一個註解用於指定需要記錄日誌的方法。後者更加靈活,同時可以添加額外的說明
@Target({ElementType.METHOD,ElementType.ANNOTATION_TYPE}) //註解可使用在方法、類/接口級別上
@Retention(RetentionPolicy.RUNTIME) //註解執行的階段爲運行時,以便在運行時獲取註解信息
@Inherited // 該註解可以被子類繼承,非必須
@Documented // 執行javadoc會自動生成文檔,非必須
public @interface OperLog {
String type() default ""; // 操作類型
String module() default ""; // 操作模塊
String requestDes() default ""; // 操作描述
}
3.3 創建切面類
3.3.1 joinPoint和ProceedingJoinPoint接口
在創建切面類之前,先了解一下JoinPoint和ProceedingJoinPoint的相關知識吧
JoinPoint接口用於表示目標類連接對象,也就是說可以通過JoinPoint實例獲取目標對象的相關信息。而在使用環繞通知的時候需要使用ProceedingJoinPoint,作爲JoinPoint的子接口,除了繼承JoinPoint的方法,還新增一個proceed方法用於通過反射執行目標對象的連接點處方法。常用的方法如下:
-
joinPoint.getTarget()
:獲取切入點所在的目標對象。 -
joinPoint.getSignature()
:獲取被織入增強的方法簽名joinPoint.getSignature().getName()
:返回方法名字joinPoint.getSignature().getDeclaringType()
:返回包名+組件名(類名)((MethodSignature) joinPoint.getSignature()).getMethod()
:返回Method對象
-
joinPoint.getThis()
:獲取代理對象本身,所包含的信息和getTarget的返回對象一致 -
jointpoint.getArgs()
:獲取切入點方法運行時的入參列表,注意返回的列表中只包含參數的值
3.3.2 切面類示例
import com.alibaba.fastjson.JSON;
import com.javatest.po.ExceptionLog;
import com.javatest.po.OperationLog;
import com.javatest.service.ExceptionLogService;
import com.javatest.service.OperationLogService;
import com.javatest.util.IpAdressUtil;
import com.javatest.util.annotation.OperLog;
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.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Aspect // 指定當前類爲切面類
@Component // 創建切面類對象
public class LoggerAspect {
private final Logger logger = LoggerFactory.getLogger(LoggerAspect.class);
@Autowired
private OperationLogService operationLogService;
@Autowired
private ExceptionLogService exceptionLogService;
// 定義請求日誌切入點表達式,掃描被@OperLog註解的方法
@Pointcut("@annotation(com.javatest.util.annotation.OperLog)")
public void operLogPt() {
}
// 定義異常日誌切入點表達式,掃描controller包下所有方法
@Pointcut("execution(* com.javatest.controller.*.*(..))")
public void excepLogPt() {
}
// 正常的請求日誌是在返回時記錄所有數據的
@AfterReturning(value = "operLogPt()",returning = "returnData")
public void addOperLog(JoinPoint joinPoint, Object returnData) {
try {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
OperationLog operationLog = new OperationLog();
// 注意,請求的數據要從切入點中反射出來
//從切面織入點處通過反射機制獲取織入點處的方法
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 獲取切入點所在的方法
Method method = signature.getMethod();
// 獲取註解上的信息
OperLog annotation = method.getAnnotation(OperLog.class);
if (annotation != null) {
operationLog.setType(annotation.type());
operationLog.setModule(annotation.module());
operationLog.setRequestDes(annotation.requestDes());
}
// 獲取請求的類路徑
String className = joinPoint.getTarget().getClass().getName();
// 獲取請求的方法名
String methodName = method.getName();
methodName = className + "." + methodName;
// 獲取POST請求參數
Map<String,String> paramMap = new HashMap<>();
Map<String, String[]> parameterMap = request.getParameterMap();
for (String key : parameterMap.keySet()) {
paramMap.put(key,parameterMap.get(key)[0]);
}
String param = JSON.toJSONString(paramMap);
// 完善信息,id爲int類型自增。可改用String傳uuid
// 從session中獲取,本地測試寫死
// operationLog.setUserId((String) request.getSession().getAttribute("userId"));
// operationLog.setUserName((String) request.getSession().getAttribute("userName"));
operationLog.setUserId("1");
operationLog.setUserName("azure");
operationLog.setIp(IpAdressUtil.getIpAddr(request));
operationLog.setUrl(request.getRequestURI());
operationLog.setMethodName(methodName);
operationLog.setRequestParam(param);
operationLog.setReturnData(JSON.toJSONString(returnData));
// operationLog.setStartTime();
// operationLog.setFinishTime();
operationLog.setReturnTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date()));
operationLogService.insertSelective(operationLog);
} catch (Exception e) {
logger.error("插入日誌失敗",e);
}
}
@AfterThrowing(pointcut = "excepLogPt()",throwing = "e")
public void addExcepLog(JoinPoint joinPoint, Throwable e){
try {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
ExceptionLog exceptionLog = new ExceptionLog();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String className = joinPoint.getTarget().getClass().getName();
String methodName = method.getName();
methodName = className + "." + methodName;
// 獲取請求參數
Object[] args = joinPoint.getArgs();
String param = JSON.toJSONString(args);
// 獲取異常信息
StringBuilder sb = new StringBuilder();
sb.append(e.getMessage()).append(";\n");
for (StackTraceElement element : e.getStackTrace()) {
sb.append(element).append("\n");
}
//完善數據,id爲int類型自增
// exceptionLog.setId();
// 同上
exceptionLog.setUserId("1");
exceptionLog.setUserName("azure");
exceptionLog.setExceptionName(e.getClass().getName());
exceptionLog.setExceptionMsg(sb.toString());
exceptionLog.setMethodName(methodName);
exceptionLog.setIp(IpAdressUtil.getIpAddr(request));
exceptionLog.setUrl(request.getRequestURI());
exceptionLog.setRequestParam(param);
exceptionLog.setCreateTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date()));
exceptionLogService.insertSelective(exceptionLog);
} catch (Exception e1) {
logger.error("插入異常日誌失敗",e1);
}
}
}
3.4 創建一個簡單的controller類,用來模擬請求
@RestController
@RequestMapping("/aop")
public class AopController {
@GetMapping("/head/{id}/{Pass}")
@OperLog(type = "測試",module = "Aop",requestDes = "測試正常情況下的日誌記錄")
public String head(@PathVariable("id") String id, @PathVariable("Pass") String password){
System.out.println(id);
System.out.println(password);
return "head";
}
@PostMapping("/head2")
@OperLog(type = "測試",module = "Aop",requestDes = "測試正常情況下的日誌記錄")
public String head2(String id, String password){
// System.out.println(id);
// System.out.println(password);
return "head";
}
@RequestMapping("/error")
public String error(){
int i = 1/0;
return "error";
}
}
3.5 測試結果
3.5.1 操作日誌結果
post請求可以正常記錄操作日誌。但get請求如果含入參時,操作日誌雖可以記錄,但不能把入參記錄下來。
如果將獲取請求參數的方法由request.getParameterMap()
改爲joinPoint.getArgs()
,雖然可以將get請求的入參記錄下來,但只記錄下入參的值,沒有記錄入參名。其優化方法還需要進一步的研究。
3.5.2 異常日誌結果
均能記錄異常日誌
優化1:改用環繞通知記錄方法執行時間
網上有些方法是創建一個對象在前置通知中封裝一些參數,在後置通知中處理這個對象;有些是將參數封裝到session中,通過sessionid來獲取session中的參數等。不過用環繞通知可能更容易實現,同時還能避免一些併發問題。
我們嘗試對上面的切面類進行改造。正如3.3.1所述,使用環繞通知時需使用ProceedingJoinPoint。
優化1.1 環繞通知的格式
@Aspect // 指定當前類爲切面類
@Component // 創建切面類對象
public class Logger {
// 定義一個切入點表達式
@Pointcut("execution(切入點表達式)")
public void pt() {
}
// 環繞目標對象方法執行
@Around("pt()")
public Object around(ProceedingJoinPoint pjp) {
try {
System.out.println("[環繞通知] 環繞前");
Object retV = pjp.proceed();
System.out.println("[環繞通知] 環繞後");
return retV;
} catch (Throwable throwable) {
throwable.printStackTrace();
System.out.println("[環繞通知] 環繞異常");
return null;
} finally {
System.out.println("[環繞通知] 環繞最終");
}
}
}
需要注意的是,使用環繞通知需要將目標方法的執行結果返回,否則前端將接收不到執行結果。
優化1.2 環繞通知改造實例
import com.alibaba.fastjson.JSON;
import com.javatest.po.ExceptionLog;
import com.javatest.po.OperationLog;
import com.javatest.service.ExceptionLogService;
import com.javatest.service.OperationLogService;
import com.javatest.util.IpAdressUtil;
import com.javatest.util.annotation.OperLog;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Aspect // 指定當前類爲切面類
@Component // 創建切面類對象
public class LoggerAspect2 {
private final Logger logger = LoggerFactory.getLogger(LoggerAspect2.class);
@Autowired
private OperationLogService operationLogService;
@Autowired
private ExceptionLogService exceptionLogService;
// 定義請求日誌切入點表達式,掃描被@OperLog註解的方法
@Pointcut("execution(* com.javatest.controller.*.*(..)) || @annotation(com.javatest.util.annotation.OperLog)")
public void operLogPt() {
}
//正常的請求日誌是在返回時記錄所有數據的
@Around(value = "operLogPt()")
public Object addOperLog(ProceedingJoinPoint joinPoint) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
OperationLog operationLog = new OperationLog();
Object object = null;
try {
/*【環繞通知】 環繞前*/
// 獲取通知前的時間
operationLog.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date()));
// 獲取被織入增強的方法簽名對象
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 獲取請求類路徑+方法名
String methodName = signature.getDeclaringTypeName() + "." + signature.getName();
operationLog.setMethodName(methodName);
// 獲取註解上的信息
OperLog annotation = signature.getMethod().getAnnotation(OperLog.class);
if (annotation != null) {
operationLog.setType(annotation.type());
operationLog.setModule(annotation.module());
operationLog.setRequestDes(annotation.requestDes());
}
// 獲取POST請求參數
Map<String, String> paramMap = new HashMap<>();
Map<String, String[]> parameterMap = request.getParameterMap();
for (String key : parameterMap.keySet()) {
paramMap.put(key, parameterMap.get(key)[0]);
}
String param = JSON.toJSONString(paramMap);
operationLog.setRequestParam(param);
// 執行方法
try {
object = joinPoint.proceed();
operationLog.setFinishTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date())); // 方法執行完時間
/*【環繞通知】 環繞後*/
try {
// 完善信息,id爲int類型自增。可改用String傳uuid
operationLog.setUserId((String) request.getSession().getAttribute("userId"));
operationLog.setUserName((String) request.getSession().getAttribute("userName"));
operationLog.setIp(IpAdressUtil.getIpAddr(request));
operationLog.setUrl(request.getRequestURI());
operationLog.setMethodName(methodName);
operationLog.setRequestParam(param);
operationLog.setReturnData(JSON.toJSONString(object));
operationLog.setReturnTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date()));
operationLogService.insertSelective(operationLog);
} catch (Exception e) {
logger.error("插入操作日誌失敗", e);
}
// 注意:環繞通知必須返回方法執行的結果
} catch (Throwable e) {
/*【環繞通知】 環繞異常*/
e.printStackTrace();
// 插入異常日誌
ExceptionLog exceptionLog = new ExceptionLog();
StringBuilder sb = new StringBuilder();
sb.append(e.getMessage()).append(";\n");
for (StackTraceElement element : e.getStackTrace()) {
sb.append(element).append("\n");
}
try {
exceptionLog.setUserId((String) request.getSession().getAttribute("userId"));
exceptionLog.setUserName((String) request.getSession().getAttribute("userName"));
exceptionLog.setExceptionName(e.getClass().getName());
exceptionLog.setExceptionMsg(sb.toString());
exceptionLog.setMethodName(methodName);
exceptionLog.setIp(IpAdressUtil.getIpAddr(request));
exceptionLog.setUrl(request.getRequestURI());
exceptionLog.setRequestParam(param);
exceptionLog.setCreateTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS").format(new Date()));
exceptionLogService.insertSelective(exceptionLog);
} catch (Exception e1) {
logger.error("插入異常日誌失敗", e1);
}
}
} catch (Exception e) {
e.printStackTrace();
}
return object;
}
}
由於環繞異常會將方法執行外拋的異常捕獲,如果需要方法的異常來觸發某些機制的話,將會與環繞異常的異常捕獲產生衝突。但一般來說,異常不建議作爲業務流程的一部分。
優化2:多aop切面下的優先級
Aspect切面類上加上@Order(n)註解即可,n越小最先執行,也就是位於最外層。
參考文獻:
Spring Boot中使用AOP記錄請求日誌
SpringBoot使用AOP記錄請求日誌和異常日誌
Spring boot中使用aop詳解
使用SpringBoot AOP 記錄操作日誌、異常日誌
springAOP實現操作日誌記錄,並記錄請求參數與編輯前後字段的具體改變
spring中用joinpoint來訪問目標方法的參數