目錄
一、前言
二、spring aop概述
2.1 什么是spring aop
2.2 spring aop特點
2.3 spring aop應用場景
三、spring aop處理通用日志場景
3.1 系統日志類型
3.2 微服務場景下通用日志記錄解決方案
3.2.1 手動記錄
3.2.2 異步隊列+es
3.2.3 使用過濾器或攔截器
3.2.4 使用aop
四、AOP記錄接口請求參數日志
4.1 場景需求分析
4.2 前置準備
4.2.1 準備兩張數據表
4.2.2 提前搭建springboot工程
4.2.3 引入核心依賴
4.2.4 配置文件
4.3 AOP實現日志記錄過程
4.3.1 添加自定義注解
4.3.2 添加AOP切面
4.3.3 測試接口
4.3.4 功能測試
五、AOPA實現記錄業務數據修改前后對比
5.1 需求分析
5.2 實現方案分析與對比
5.2.1 canal實現方案
5.2.2 aop實現方案
5.3 aop實現接口執行前后數據變化記錄
5.3.1 準備一張日志表
5.3.2 自定義日志注解
5.3.3 重寫InvocationHandler
5.3.4 增加兩個業務測試接口
5.3.5 aop核心實現類
5.3.6 接口效果測試
5.4 參數值對比
5.4.1 創建字段變更記錄表
5.4.2 創建自定義注解
5.4.3 提供一個反射工具類
5.4.4 aop代碼改造
5.4.5 代碼測試
六、寫在文末
一、前言
spring aop技術在日常的開發中應用場景很多,AOP,即面向切面編程技術,它提供了一種在應用程序中將橫切關注點(例如日志記錄、事務管理和安全性)與主要業務邏輯分離的方式。利用aop技術可以解決某些特定場景下看似復雜的需求,本文將通過兩個實際案例,分享利用aop解決日志變更相關的場景。
二、spring aop概述
2.1 什么是spring aop
Spring AOP,全稱:Aspect-Oriented Programming,是Spring框架提供的一個面向切面編程的功能模塊。它允許開發人員通過定義切面來解耦和管理應用程序中的橫切關注點。
-
在傳統的面向對象編程中,我們將代碼組織為類和對象,并通過繼承和組合來實現模塊化和復用。然而,某些功能(如日志記錄、事務管理、安全性檢查等)可能會散布在整個應用程序中,導致代碼重復和耦合。
-
Spring AOP通過引入切面的概念來解決這個問題。切面是一種模塊化單元,它包含了與橫切關注點相關的切點和通知。切點定義了在應用程序中插入切面邏輯的位置,而通知定義了在切點處要執行的具體操作。
-
Spring AOP使用動態代理技術,在運行時創建代理對象來實現切面功能。當目標對象上的方法被調用時,AOP代理截獲方法調用并在切點處執行相應的通知。這使得開發人員可以將橫切關注點從主要業務邏輯中抽離出來,以提高代碼的可重用性和可維護性。
-
Spring AOP支持不同類型的通知,包括前置通知(Before)、后置通知(After)、異常通知(After-throwing)和環繞通知(Around)。開發人員可以根據自己的需求選擇適當的通知類型。
-
除了XML配置外,Spring AOP還支持使用注解來聲明切面和切點,使得配置更加簡潔和直觀。它與Spring的IOC容器無縫集成,可以輕松地將切面應用于Spring管理的bean。
2.2 spring aop特點
Spring AOP(Aspect-Oriented Programming)具有以下特點:
-
解耦與模塊化:Spring AOP通過將橫切關注點從主要業務邏輯中解耦出來,實現了代碼的模塊化和復用。開發人員可以將通用功能(如日志記錄、事務管理等)封裝為切面,并在需要的地方進行應用,而不必修改現有的業務邏輯。
-
運行時動態代理:Spring AOP使用動態代理技術,在運行時創建代理對象并將切面邏輯織入目標對象的方法調用中。這允許開發人員在不改變原始代碼的情況下添加額外的行為,以滿足特定的需求。
-
支持多種通知類型:Spring AOP支持多種通知類型,包括前置通知(Before)、后置通知(After)、環繞通知(Around)、返回通知(After-returning)和異常通知(After-throwing)。這使得開發人員能夠根據具體的場景選擇適合的通知類型。
-
靈活的切點表達式:Spring AOP使用切點表達式來確定在哪些位置應用切面邏輯。切點表達式可以根據方法的名稱、參數類型、類的位置等條件進行匹配,提供了很大的靈活性和精確度。
-
集成簡單:Spring AOP與Spring的IOC容器無縫集成,可以輕松地將切面應用于Spring管理的bean。開發人員可以通過簡單的配置或注解來聲明切面和切點,使得代碼的組織和管理更加方便。
-
可擴展性強:Spring AOP提供了一套可擴展的機制,允許開發人員自定義切面和通知。通過實現自定義的切面和通知,開發人員可以根據需求添加額外的功能或行為。
2.3 spring aop應用場景
Spring AOP可以應用于各種場景,以下列舉了開發中常用的場景,根據這些場景描述可以選擇使用:
-
日志記錄:通過使用AOP,在方法執行前后添加日志記錄的功能,可以方便地記錄請求參數、返回結果等信息,有助于系統的調試和監控。
-
事務管理:AOP可以將事務管理從業務邏輯中解耦出來,確保數據的一致性和完整性。在方法執行前后添加事務相關的操作,例如開啟、提交或回滾事務。
-
安全性控制:通過AOP可以實現對敏感操作進行權限驗證,例如檢查用戶是否具有足夠的權限進行某項操作,以增強系統的安全性。
-
性能監控:使用AOP可以在方法調用前后進行計時,并對方法的執行時間進行統計和監控,有助于發現性能瓶頸并進行優化。
-
異常處理:AOP可以捕獲方法執行過程中的異常,并進行統一的處理。例如,可以記錄異常信息、發送通知或執行特定的補償操作。
-
緩存管理:通過AOP可以在方法執行前后進行緩存的讀取和寫入操作,提高系統的響應速度和性能。
-
日志審計:通過AOP可以在關鍵業務操作執行后記錄審計日志,用于追蹤和分析系統的操作歷史和行為。
-
異步處理:通過AOP可以將某些耗時的操作異步化,提高系統的并發性和響應能力。
三、spring aop處理通用日志場景
在上面aop的場景使用中談到,在方法執行前后,利用aop技術,可以添加日志記錄的功能,方便地記錄請求參數、返回結果等信息,有助于系統的調試和監控。
3.1 系統日志類型
可以說,當微服務規模越來越大,系統業務越來越復雜,日志記錄的作用越來越大,比如系統中常見的日志類型有:
-
請求日志:
-
記錄請求的相關信息,如請求的URL、HTTP方法、請求參數等。這種日志可以幫助開發人員追蹤和調試請求的處理過程。
-
-
響應日志
-
記錄響應的相關信息,如響應狀態碼、響應頭、返回結果等。這種日志可以幫助開發人員了解系統對請求的處理結果。
-
-
錯誤日志
-
記錄系統中發生的錯誤和異常信息,包括堆棧軌跡、錯誤代碼、錯誤描述等。這種日志可以幫助開發人員快速定位和解決問題。
-
-
業務日志(審計日志):
-
根據具體業務需求記錄的日志信息,例如用戶操作日志、支付記錄、訂單跟蹤等。這種日志可以用于審計、統計和數據分析等目的。
-
-
性能日志
-
記錄系統的性能指標,如請求處理時間、內存使用情況、數據庫查詢時間等。這種日志可以用于監控系統的性能,并進行性能優化。
-
-
安全日志
-
記錄系統中的安全事件和操作,如登錄嘗試、權限驗證等。這種日志可以用于安全審計和檢測潛在的安全風險。
-
-
接口變更日志
-
針對增刪改接口,記錄核心API接口的參數變更,便于統一對接口進行審查、管控和后續的記錄贅述。
-
可以說,日志記錄,在任何系統中都有著重要的意義,尤其是那些大型的微服務架構下,服務鏈路異常復雜的情況下,記錄日志可以給后面的運維、審計等工作帶來方便。
3.2 微服務場景下通用日志記錄解決方案
以某個場景下的日志類型為例進行說明,比如這里以API接口的參數變更記錄日志為例進行說明,下面列舉幾種常用的解決方案。
3.2.1 手動記錄
在每個關鍵的接口方法中,手動記錄請求參數到日志文件或數據庫。這可以通過使用日志框架(如Logback、Log4j)或自定義的記錄器來實現。
-
優點:代碼可以靈活的控制記錄參數的個數,輸出格式,以及參數的存儲形式;
-
缺點:代碼侵入性強,比較繁瑣,記錄日志的位置太多的話,需要額外投入不少工作量;
3.2.2 異步隊列+es
在需要記錄日志的方法中,通過發布事件的方式,將參數異步推送到消息隊列,再有監聽服務統一處理,存儲es進行展示。
-
優點:代碼侵入性較少,并且日志記錄與方法主業務盡量解耦;
-
缺點:架構稍顯復雜,鏈路較長,適合規模相對較大的項目;
3.2.3 使用過濾器或攔截器
在過濾器或攔截器中,攔截請求路徑和請求參數,從而記錄日志。
3.2.4 使用aop
使用AOP技術,在方法執行前后自動記錄請求參數到日志中。可以通過Spring AOP等框架來實現切面邏輯,將參數日志記錄邏輯與業務邏輯解耦。
四、AOP記錄接口請求參數日志
4.1 場景需求分析
在開發中經常會遇到這樣的需求,你們系統中能不能記錄下某某時刻,某某人在系統中做了一個什么操作,這就是典型的日志記錄場景。程序中通過記錄日志并存儲日志,從而方便后續的審計和運維。
具體到開發這一層,則需要設計出一種方案,能對關鍵的交互操作對應的API接口參數進行解析、處理和記錄,那么容易想到并且也比較通用的一種解決方案就是使用自定義注解+AOP的方式來解決這個問題。
4.2 前置準備
4.2.1 準備兩張數據表
業務用戶表 tb_user
CREATE TABLE `tb_user` (`id` varchar(64) NOT NULL COMMENT '主鍵id',`user_name` varchar(64) DEFAULT NULL COMMENT '用戶名',`nick_name` varchar(64) DEFAULT NULL COMMENT '昵稱',`address` varchar(64) DEFAULT NULL COMMENT '地址',`phone` varchar(40) DEFAULT NULL COMMENT '手機號',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用戶表';INSERT INTO `pm-db`.`tb_user`(`id`, `user_name`, `nick_name`, `address`, `phone`) VALUES ('1', 'mike', 'mike', 'shanghai', '183***');
日志記錄表 operate_log
CREATE TABLE `operate_log` (`id` varchar(64) NOT NULL COMMENT '主鍵id',`title` varchar(64) DEFAULT NULL COMMENT '標題',`business_type` tinyint(2) DEFAULT NULL COMMENT '操作類型',`method` varchar(64) DEFAULT NULL COMMENT '操作方法名稱',`operation` varchar(40) DEFAULT NULL COMMENT '操作簡單描述',`operator_type` tinyint(2) DEFAULT '0' COMMENT '操作類型',`oper_ip` varchar(32) DEFAULT NULL COMMENT '操作人IP',`oper_params` varchar(512) DEFAULT NULL COMMENT '操作參數',`desc` varchar(512) DEFAULT NULL COMMENT '描述',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='操作日志表';
4.2.2 提前搭建springboot工程
略
4.2.3 引入核心依賴
<dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId><version>3.4</version></dependency><dependency><groupId>commons-beanutils</groupId><artifactId>commons-beanutils</artifactId><version>1.9.3</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency> <dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.2.2</version></dependency><dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId><version>1.1.17</version></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>2.0.23</version></dependency>
4.2.4 配置文件
server:port: 8088spring:application:name: user-servicedatasource:url: jdbc:mysql://數據庫連接IP:3306/pm-dbdriverClassName: com.mysql.jdbc.Driverusername: rootpassword: rootmybatis:mapper-locations: classpath:mapper/*.xml#目的是為了省略resultType里的代碼量type-aliases-package: com.congge.entityconfiguration:log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
4.3 AOP實現日志記錄過程
4.3.1 添加自定義注解
自定義一個注解,后面需要記錄日志的方法上面可以添加該注解,注解中的參數可以根據實際業務情況補充
import java.lang.annotation.*;@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {/*** 模塊*/String title() default "";/*** 模塊分類* @return*/String catalog() default "";/*** 操作應用*/String operApplication() default "";/*** 功能*/BusinessType businessType() default BusinessType.OTHER;
}
4.3.2 添加AOP切面
該類主要做的事情如下:
-
使用aop的環繞通知;
-
切點為添加了自定義注解的方法;
-
環繞通知方法中,首先解析自定義注解中的參數信息,作為后面組裝日志對象使用;
-
解析本次請求的方法參數,對請求中的對象里面的參數進行拆解,封裝到日志對象屬性中;
-
等待目標方法執行完成,將本次封裝好的日志對象數據插入到日志表;
import com.congge.dao.OperateLogMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.beanutils.PropertyUtils;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;import javax.annotation.Resource;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;@Aspect
@Component
public class TbUserLogAspect {@Resourceprivate OperateLogMapper operateLogMapper;static Map<String,Integer> businessTypes = new HashMap<>();private static final ObjectMapper objectMapper = new ObjectMapper();static {businessTypes.put("saveUser",1);businessTypes.put("updateUser",2);businessTypes.put("deleteUser",3);}/*** 記錄租戶同步部門或者用戶的數據日志* @annotation(log) : 即表示為 @Log 的注解** @param joinPoint* @param log* @return* @throws Throwable*/@Around(value = "@annotation(log)", argNames = "joinPoint, log")public Object methodAround(ProceedingJoinPoint joinPoint, Log log) throws Throwable {BusinessType businessType = log.businessType();String title = log.title();int bizType = businessType.getBusinessType();OperateLog sysOperLog = new OperateLog();sysOperLog.setId(UUID.randomUUID().toString());sysOperLog.setTitle(title);sysOperLog.setOperatorType(log.businessType().getBusinessType());sysOperLog.setMethod(joinPoint.getSignature().getName());Map<String, Object> argsParams = getFieldsName(joinPoint);sysOperLog.setDesc(title);sysOperLog.setOperParams(objectMapper.writeValueAsString(argsParams));sysOperLog.setBusinessType(bizType);//執行方法Object result = joinPoint.proceed();operateLogMapper.saveLog(sysOperLog);return result;}/*** 獲取AOP參數信息* @param joinPoint* @return*/private static Map<String, Object> getFieldsName(ProceedingJoinPoint joinPoint) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException {// 參數值Object[] args = joinPoint.getArgs();Map<String, Object> paramMap = new HashMap<>(32);for(Object obj : args){Map<String, Object> objMap = PropertyUtils.describe(obj);objMap.forEach((key,val) ->{paramMap.put(key,val);});}return paramMap;}
}
4.3.3 測試接口
添加一個測試接口,使用上述自定義注解
@PostMapping("/save")@Log(title = "新增用戶", catalog = "saveUser", businessType = BusinessType.INSERT)public String saveUser(@RequestBody TbUser tbUser){return tbUserService.saveUser(tbUser);}
4.3.4 功能測試
運行工程,然后通過接口工具調用一下
執行成功后,可以看到日志表中記錄了一條數據,當然,如果你希望記錄的參數更完整一些,可以再在程序中補充完善即可。
五、AOPA實現記錄業務數據修改前后對比
5.1 需求分析
與上述業務需求場景不同的是,產品現在給出了另一個需求,不僅要記錄增刪改的參數日志,針對某個或某些特殊的業務,還需要記錄這個接口本次修改時,哪些參數發生了變化,修改之前字段是什么?修改之后變成了什么?
這個需求,看起來好像也不難,但是真正在動手操作的時候會發現,要是做好這個需求,盡量做到通用簡單,還是需要考慮很多點的,下面列舉兩種可行的實現方案。
5.2 實現方案分析與對比
5.2.1 canal實現方案
canal是阿里開源的一款很好用的做數據同步的工具,基于canal提供的功能,可以為架構設計帶來很多便利,好奇的同學可能會問,怎么利用canal實現接口參數變更前后的記錄操作呢?參考下面的這張圖,如何利用canal來實現這個方案。
結合上圖,實現的過程如下:
-
mysql開啟binlog;
-
部署canal服務,監聽業務數據庫下的表;
-
程序引入canal - sdk,監聽特定接口的變更事件;
-
利用canal解析變更前后的參數,canal-sdk中可以拿到某一條數據的元數據信息;
5.2.2 aop實現方案
在上面的討論中,使用aop的方式實現了接口請求參數的日志記錄,利用aop,如果設計得當,同樣可以實現update接口前后參數對比的記錄,參考下面這張圖;
核心實現思路:
-
自定義log注解,主要是定義待記錄到數據表中的基本參數字段;
-
使用aop環繞通知,解析自定義注解和接口請求參數;
-
通過jdk動態代理,獲取mybatis的mapper接口代理對象;
-
利用mapper的代理對象對ID代表的業務表數據在接口操作前后進行查詢,和對比;
-
最后對變化的數據進行記錄;
5.3 aop實現接口執行前后數據變化記錄
基于上述使用aop的實現方案,下面來看具體的實現過程。
5.3.1 準備一張日志表
提前創建一張用于記錄數據變更記錄的操作日志表
CREATE TABLE `sql_log` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',`module_name` varchar(64) DEFAULT NULL COMMENT '模塊名',`operate_type` varchar(16) DEFAULT NULL COMMENT '操作類型',`params` text COMMENT '請求參數',`old_data` text COMMENT '原始數據',`now_data` text COMMENT '現在的數據',`clazz` varchar(255) DEFAULT NULL COMMENT '類名',`biz_type` varchar(64) DEFAULT NULL COMMENT '業務類型',`remark` varchar(128) DEFAULT NULL COMMENT '備注'PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
5.3.2 自定義日志注解
注解中的屬性,一般可以結合日志表,以及輔助日志記錄的業務進行預定義
import java.lang.annotation.*;@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SqlLog {/*** 操作類型:delete update insert* @return*/String operateType() default "";/*** 業務類型* @return*/String bizType() default "";/*** 查詢舊數據的方法名* @return*/String queryName() default "getById";/*** mapper的全路徑:* com.ylfin.user.repository.MemberPrivilegeMapper* @return*/String mapper() default "";/*** 主鍵的字段名* @return*/String keyName() default "id";/*** 描述* @return*/String desc() default "";}
5.3.3 重寫InvocationHandler
還記得在編寫JDK動態代理代碼的時候,是如何自定義代理類的嗎,簡單來講就是實現InvocationHandler接口,重寫里面的invoke方法,這里自定義一個InvocationHandler,用于在aop類中獲取mapper接口的實例,即mapper的代理對象。
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;public class MyInvocationHandler implements InvocationHandler {private Object target;public MyInvocationHandler(Object target) {this.target = target;}@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {return method.invoke(target, args);}}
5.3.4 增加兩個業務測試接口
修改接口,同時添加上述的自定義日志注解
@PostMapping("/update")@SqlLog(operateType = "SAVE_OR_UPDATE", bizType = "MEMBER_PRIVILEGE", queryName = "getById",keyName = "id", mapper = "com.congge.dao.TbUserMapper", desc = "修改用戶")public String updateUser(@RequestBody TbUser tbUser){return tbUserService.updateUser(tbUser);}
查詢接口
//localhost:8088/getById?id=001@GetMapping("/getById")public TbUser getById(@RequestParam String id){return tbUserService.getById(id);}
TbUserMapper接口,在這個方案實現中具有重要的角色
-
操作mybatis的增上刪改查;
-
同時將TbUserMapper接口的全路徑名稱配置在log注解中作為一個屬性;
-
在TbUserMapper接口中,定義了一個getById的查詢方法,作為aop解析參數并反射執行查詢的時候使用;
import com.congge.entity.TbUser;
import org.apache.ibatis.annotations.Param;public interface TbUserMapper {void saveUser(TbUser tbUser);TbUser getById(@Param("id") String id);String updateUser(TbUser tbUser);
}
5.3.5 aop核心實現類
結合代碼中的方法注釋進行理解,編碼過程的思路與上述aop實現方案中展現的流程基本一致,可以對照著理解
import com.alibaba.fastjson.JSONObject;
import com.congge.dao.SqlLogMapper;
import com.congge.entity.SqlLogPo;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.beanutils.PropertyUtils;
import org.apache.ibatis.session.SqlSession;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;@Slf4j
@Aspect
@Component
public class SqlLogAspect {@Autowiredprivate SqlSession sqlSession;@Resourceprivate SqlLogMapper sqlLogMapper;@Value("${spring.application.name}")private String moduleName;@Pointcut(value = "@annotation(SqlLog)")public void pointCut() {}@Around(value = "pointCut()")public Object doAround(ProceedingJoinPoint point) throws Throwable {MethodSignature signature = (MethodSignature)point.getSignature();SqlLog sqlLog = signature.getMethod().getAnnotation(SqlLog.class);String opType = sqlLog.operateType();Object result = null;//獲取請求的參數名和取值String[] parameterNames = signature.getParameterNames();Object[] args = point.getArgs();if (parameterNames == null || parameterNames.length <= 0) {log.warn("【sql切面】無請求參數!");return this.execute(point, args);}Map<String, Object> param = new HashMap<>(32);for(Object obj : args){Map<String, Object> objMap = PropertyUtils.describe(obj);objMap.forEach((key,val) ->{param.put(key,val);});}//創建日志對象SqlLogPo sqlLogPo = this.initSqlLogPo(sqlLog, param);String mapperName = sqlLog.mapper();Class<?> daoClass = Class.forName(mapperName);if (this.isUpdate(opType)) {Object firstArgs = args[0];//需要保證接口第一個參數是ID或者是包含ID的對象Object id = this.getPrimaryKey(firstArgs, daoClass, sqlLog.keyName());//新增數據if (Objects.isNull(id)) {sqlLogPo.setNowData(JSONObject.toJSONString(firstArgs));result = this.execute(point, args);sqlLogPo.setClazz(firstArgs.getClass().getName());sqlLogMapper.insertSelective(sqlLogPo);return result;}//獲取ID的類型Class keyClz = Class.forName(id.getClass().getName());Object instance = this.getMapperInstance(daoClass);//通過反射機制實現查詢方法Method method = instance.getClass().getMethod(sqlLog.queryName(), keyClz);//查詢操作之前的數據Object invoke = method.invoke(instance, keyClz.cast(id));if (Objects.nonNull(invoke)) {sqlLogPo.setOldData(JSONObject.toJSONString(invoke));}result = this.execute(point, args);//邏輯刪除之后不用再查一遍數據if (!"DELETE".equals(opType)) {//查詢操作之后的數據invoke = method.invoke(instance, keyClz.cast(id));if (Objects.nonNull(invoke)) {sqlLogPo.setNowData(JSONObject.toJSONString(invoke));sqlLogPo.setClazz(invoke.getClass().getName());}}sqlLogMapper.insertSelective(sqlLogPo);}log.info("############### 結束sql切面 ###############");return result;}/*** 執行代理的方法* @param point* @param args* @return* @throws Throwable*/private Object execute(ProceedingJoinPoint point, Object[] args) throws Throwable {try {return point.proceed(args);} catch (Exception e) {throw e;}}/*** 獲取主鍵的值* @param firstArgs* @param daoClass* @return*/private Object getPrimaryKey(Object firstArgs, Class<?> daoClass, String keyName) {if (firstArgs instanceof Long) {return firstArgs;}//第一個參數是對象的轉成mapObjectMapper objectMapper = new ObjectMapper();String str = null;try {str = objectMapper.writeValueAsString(firstArgs);} catch (JsonProcessingException e) {throw new RuntimeException(e);}Map map = JSONObject.parseObject(str,Map.class);return map.get(keyName);}/*** 初始化日志對象* @param sqlLog* @param param* @return*/private SqlLogPo initSqlLogPo(SqlLog sqlLog, Map param) {SqlLogPo sqlLogPo = new SqlLogPo();sqlLogPo.setBizType(sqlLog.bizType());sqlLogPo.setOperateType(sqlLog.operateType());sqlLogPo.setModuleName(moduleName);sqlLogPo.setRemark(sqlLog.desc());sqlLogPo.setParams(JSONObject.toJSONString(param));return sqlLogPo;}/*** 獲取mapper的實例* @param daoClass* @return*/private Object getMapperInstance(Class<?> daoClass) {Object instance = Proxy.newProxyInstance(daoClass.getClassLoader(),new Class[]{daoClass},new MyInvocationHandler(sqlSession.getMapper(daoClass)));return instance;}/*** 是否更新數據* @param opType* @return*/private boolean isUpdate(String opType) {return "UPDATE".equals(opType) || "SAVE_OR_UPDATE".equals(opType)|| "LOGIC_DELETE".equals(opType);}}
5.3.6 接口效果測試
在數據庫的tb_user表準備一條數據
INSERT INTO `pm-db`.`tb_user`(`id`, `user_name`, `nick_name`, `address`, `phone`) VALUES ('1', 'mike', 'mike', 'shanghai', '183***');
在postman工具中調用接口進行修改
執行完畢之后,在sql_log表中可以看到記錄的日志的完整數據,重點對比本次修改的字段,在上述接口中,對id為1的這條數據,我們修改了user_name和 nick_name字段,通過上面的執行,在數據庫的sql_log這張表中,old_data和new_data參數中,也記錄了下來;
5.4 參數值對比
更進一步來說,當上述方案實現了對接口更新前后的參數數據記錄之后,用戶希望知道,本次操作人員究竟修改了哪個字段?修的值是什么?修改之前是什么,修改之后是什么?類似于下面這張圖的展示,畢竟僅僅將sql_log中記錄的原始數據拿出來展示的話并不是很直觀,那么就需要對上面的aop實現代碼做進一步的完善。完整的實現思路如下:
-
創建字段變更記錄表;
-
自定義注解,標注請求對象的屬性;
-
提供一個反射工具類,能夠動態讀取封裝的業務請求對象中的字段值,進行對比;
5.4.1 創建字段變更記錄表
該表用于記錄字段變化的值
CREATE TABLE `field_change_log` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',`biz_id` varchar(64) DEFAULT NULL COMMENT '業務ID',`change_field` varchar(32) DEFAULT NULL COMMENT '變更字段名稱',`before` varchar(32) DEFAULT NULL COMMENT '變更之前',`after` varchar(32) DEFAULT NULL COMMENT '變更之后',`remark` varchar(128) DEFAULT NULL COMMENT '備注',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
5.4.2 創建自定義注解
該注解用于標注業務參數對象的屬性,描述字段的中文含義
import java.lang.annotation.*;@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
@Documented
public @interface DataName {// 字段名稱String name() default "";
}
基于該注解改造TbUser對象
@Data
@NoArgsConstructor
public class TbUser {@DataName(name = "用戶ID")private String id;@DataName(name = "用戶名")private String userName;@DataName(name = "昵稱")private String nickName;@DataName(name = "地址")private String address;@DataName(name = "手機號")private String phone;public TbUser(String id){this.id=id;}
}
5.4.3 提供一個反射工具類
該工具類核心為,讀取和解析添加了自定義注解的請求對象,解析請求對象前后的字段數據,進行對比得出結果
import com.congge.log.DataName;
import lombok.extern.slf4j.Slf4j;import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;@Slf4j
public class ReflectionUtils {/*** 直接讀取對象屬性值, 無視private/protected修飾符, 不經過getter函數.* @param obj 讀取的對象* @param fieldName 讀取的列* @return 屬性值*/public static Object getFieldValue(final Object obj, final String fieldName) {Field field = getAccessibleField(obj, fieldName);if (field == null) {throw new IllegalArgumentException("Could not find field [" + fieldName + "] on target [" + obj + "]");}Object result = null;try {result = field.get(obj);} catch (IllegalAccessException e) {log.error("不可能拋出的異常{}", e.getMessage());e.printStackTrace();}return result;}/*** 循環向上轉型, 獲取對象的DeclaredField, 并強制設置為可訪問.如向上轉型到Object仍無法找到, 返回null.* @param obj 查找的對象* @param fieldName 列名* @return 列*/public static Field getAccessibleField(final Object obj, final String fieldName) {for (Class<?> superClass = obj.getClass(); superClass != Object.class; superClass = superClass.getSuperclass()) {try {Field field = superClass.getDeclaredField(fieldName);makeAccessible(field);return field;} catch (NoSuchFieldException e) { // NOSONAR// Field不在當前類定義,繼續向上轉型e.printStackTrace();continue; // new add}}return null;}/*** 改變private/protected的成員變量為public,盡量不調用實際改動的語句,避免JDK的SecurityManager抱怨。* @param*/public static void makeAccessible(Field field) {if ((!Modifier.isPublic(field.getModifiers()) || !Modifier.isPublic(field.getDeclaringClass().getModifiers()) || Modifier.isFinal(field.getModifiers())) && !field.isAccessible()) {field.setAccessible(true);}}/*** 獲取兩個對象同名屬性內容不相同的列表* @param class1 old對象* @param class2 new對象* @return 區別列表* @throws ClassNotFoundException 異常* @throws IllegalAccessException 異常*/public static List<Map<String ,Object>> compareTwoClass(Object class1, Object class2) throws ClassNotFoundException, IllegalAccessException {List<Map<String,Object>> list=new ArrayList<>();// 獲取對象的classClass<?> clazz1 = class1.getClass();Class<?> clazz2 = class2.getClass();// 獲取對象的屬性列表Field[] field1 = clazz1.getDeclaredFields();Field[] field2 = clazz2.getDeclaredFields();StringBuilder sb=new StringBuilder();// 遍歷屬性列表field1for(int i=0;i<field1.length;i++) {// 遍歷屬性列表field2for (int j = 0; j < field2.length; j++) {// 如果field1[i]屬性名與field2[j]屬性名內容相同if (field1[i].getName().equals(field2[j].getName())) {if (field1[i].getName().equals(field2[j].getName())) {field1[i].setAccessible(true);field2[j].setAccessible(true);// 如果field1[i]屬性值與field2[j]屬性值內容不相同if (!compareTwo(field1[i].get(class1), field2[j].get(class2))) {Map<String, Object> map2 = new HashMap<>();DataName name=field1[i].getAnnotation(DataName.class);String fieldName="";if(name!=null){fieldName=name.name();} else {fieldName=field1[i].getName();}map2.put("name", fieldName);map2.put("old", field1[i].get(class1));map2.put("new", field2[j].get(class2));list.add(map2);}break;}}}}return list;}/*** 對比兩個數據是否內容相同* @param object1 比較對象1* @param object2 比較對象2* @return boolean類型*/public static boolean compareTwo(Object object1,Object object2){if(object1==null&&object2==null){return true;}if(object1==null&&object2!=null){return false;}if(object1.equals(object2)){return true;}return false;}
}
5.4.4 aop代碼改造
改造后的代碼如下
package com.congge.log;import com.alibaba.fastjson.JSONObject;
import com.congge.dao.FieldChangeLogMapper;
import com.congge.dao.SqlLogMapper;
import com.congge.entity.FieldChangeLog;
import com.congge.entity.SqlLogPo;
import com.congge.utils.ReflectionUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.beanutils.PropertyUtils;
import org.apache.ibatis.session.SqlSession;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.*;@Slf4j
@Aspect
@Component
public class SqlLogAspect {@Autowiredprivate SqlSession sqlSession;@Resourceprivate SqlLogMapper sqlLogMapper;@Value("${spring.application.name}")private String moduleName;@Pointcut(value = "@annotation(SqlLog)")public void pointCut() {}@Around(value = "pointCut()")public Object doAround(ProceedingJoinPoint point) throws Throwable {MethodSignature signature = (MethodSignature)point.getSignature();SqlLog sqlLog = signature.getMethod().getAnnotation(SqlLog.class);String opType = sqlLog.operateType();Object result = null;//獲取請求的參數名和取值String[] parameterNames = signature.getParameterNames();Object[] args = point.getArgs();if (parameterNames == null || parameterNames.length <= 0) {log.warn("【sql切面】無請求參數!");return this.execute(point, args);}Map<String, Object> param = new HashMap<>(32);for(Object obj : args){Map<String, Object> objMap = PropertyUtils.describe(obj);objMap.forEach((key,val) ->{param.put(key,val);});}//創建日志對象SqlLogPo sqlLogPo = this.initSqlLogPo(sqlLog, param);String mapperName = sqlLog.mapper();Class<?> daoClass = Class.forName(mapperName);if (this.isUpdate(opType)) {Object firstArgs = args[0];//需要保證接口第一個參數是ID或者是包含ID的對象Object id = this.getPrimaryKey(firstArgs, daoClass, sqlLog.keyName());//新增數據if (Objects.isNull(id)) {sqlLogPo.setNowData(JSONObject.toJSONString(firstArgs));result = this.execute(point, args);sqlLogPo.setClazz(firstArgs.getClass().getName());sqlLogMapper.insertSelective(sqlLogPo);return result;}//獲取ID的類型Class keyClz = Class.forName(id.getClass().getName());Object instance = this.getMapperInstance(daoClass);//通過反射機制實現查詢方法Method method = instance.getClass().getMethod(sqlLog.queryName(), keyClz);//查詢操作之前的數據Object invoke = method.invoke(instance, keyClz.cast(id));//執行前的對象數據Object beforeObj = invoke;if (Objects.nonNull(invoke)) {sqlLogPo.setOldData(JSONObject.toJSONString(invoke));}result = this.execute(point, args);if("fail".equals(result)){return result;}//查詢操作之后的數據invoke = method.invoke(instance, keyClz.cast(id));if (Objects.nonNull(invoke)) {sqlLogPo.setNowData(JSONObject.toJSONString(invoke));sqlLogPo.setClazz(invoke.getClass().getName());}//執行后的對象數據Object afterObj = invoke;List<Map<String, Object>> maps = ReflectionUtils.compareTwoClass(beforeObj, afterObj);System.out.println(maps);if(!CollectionUtils.isEmpty(maps)){for(Map<String, Object> map :maps ){FieldChangeLog fieldChangeLog = new FieldChangeLog();fieldChangeLog.setBizId(id.toString());fieldChangeLog.setChangeField(map.get("name").toString());fieldChangeLog.setBefore(map.get("old").toString());fieldChangeLog.setAfter(map.get("new").toString());fieldChangeLogMapper.insertLog(fieldChangeLog);}}sqlLogMapper.insertSelective(sqlLogPo);}return result;}@Resourceprivate FieldChangeLogMapper fieldChangeLogMapper;/*** 執行代理的方法* @param point* @param args* @return* @throws Throwable*/private Object execute(ProceedingJoinPoint point, Object[] args) throws Throwable {try {return point.proceed(args);} catch (Exception e) {throw e;}}/*** 獲取主鍵的值* @param firstArgs* @param daoClass* @return*/private Object getPrimaryKey(Object firstArgs, Class<?> daoClass, String keyName) {if (firstArgs instanceof Long) {return firstArgs;}//第一個參數是對象的轉成mapObjectMapper objectMapper = new ObjectMapper();String str = null;try {str = objectMapper.writeValueAsString(firstArgs);} catch (JsonProcessingException e) {throw new RuntimeException(e);}Map map = JSONObject.parseObject(str,Map.class);return map.get(keyName);}/*** 初始化日志對象* @param sqlLog* @param param* @return*/private SqlLogPo initSqlLogPo(SqlLog sqlLog, Map param) {SqlLogPo sqlLogPo = new SqlLogPo();sqlLogPo.setBizType(sqlLog.bizType());sqlLogPo.setOperateType(sqlLog.operateType());sqlLogPo.setModuleName(moduleName);sqlLogPo.setRemark(sqlLog.desc());sqlLogPo.setParams(JSONObject.toJSONString(param));return sqlLogPo;}/*** 獲取mapper的實例* @param daoClass* @return*/private Object getMapperInstance(Class<?> daoClass) {Object instance = Proxy.newProxyInstance(daoClass.getClassLoader(),new Class[]{daoClass},new MyInvocationHandler(sqlSession.getMapper(daoClass)));return instance;}/*** 是否更新數據* @param opType* @return*/private boolean isUpdate(String opType) {return "UPDATE".equals(opType) || "SAVE_OR_UPDATE".equals(opType)|| "LOGIC_DELETE".equals(opType);}}
5.4.5 代碼測試
恢復之前的數據,再次執行update接口,執行成功后
檢查field_change_log表,可以看到,變化的字段數據按照預期效果插入到了該表中
六、寫在文末
本文通過較大的篇幅,從一個日志記錄的需求場景出發,利用工程案例詳細總結了實現的過程,對于日志記錄的需求場景,可以說不管是任何的項目中都有著重要的實踐意義,這個需求看起來不大,實際上在代碼編寫過程中,由于涉及到的技術細節較多,對于不少同學來說容易犯錯,希望本文能夠提供一個完整的參考,本篇到此結束,感謝觀看。