目录
一、前言
二、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表,可以看到,变化的字段数据按照预期效果插入到了该表中
六、写在文末
本文通过较大的篇幅,从一个日志记录的需求场景出发,利用工程案例详细总结了实现的过程,对于日志记录的需求场景,可以说不管是任何的项目中都有着重要的实践意义,这个需求看起来不大,实际上在代码编写过程中,由于涉及到的技术细节较多,对于不少同学来说容易犯错,希望本文能够提供一个完整的参考,本篇到此结束,感谢观看。