赞
踩
通过spring aop记录接口的执行日志,或者基于spel表达式记录动态日志(提取),已经是非常常见技术实现。它们都是基于接口签名及入参的日志记录,主要在于请求一侧。 而对用户发起业务请求的过程, 产生了什么数据变化及其结果侧处理,目前缺少较好的技术实现方案。 本文记录通过spring aop实现记录mybatis-plus mapper接口,在执行删除及修改数据时操作日志,自动比对历史数据. 分析字段数据变化。 来探讨记录日志结果一种技术方案。
自动数据比对的功能。涉及到某一个接口执行过程中,新增/删除/修改
目前看到大部分实现基于spel表达式加日志上下文中传递“旧”对象操作。(oldObject方法提到方法入参上也可以)
首先这种方式已经有了一定的封装性。相比完全手写方式有很大的改进。但是在笔者看来还是不够优雅。
产出上面问题得根本原因在于,有"业务含义"的日志一般在接口层或者业务层,就是我们说所得controller和service. 而数据变化的操作往往是在持久层(dao). 由于一般上层是不知道下层逻辑。那么谁知道呢? 只有研发人员知道,因此需要人为编写逻辑介入,比如正确获取旧数据并调用方法比对数据差异,这部分代码其实是业务无关的, 这样就导致了一定入侵性及编码成本。
解决之道: 让数据变化记录回到持久层处理, 不破分层的结构才能沿用aop方式无入侵处理
那么回到持久层后存在两个问题。
首先第一问题可以根据当前得线程标记当前方法在上层日志切面范围,即日志上下文。不同表dao操作可以根据当前web请求生成的traceId关联,这个不在本次介绍重点,可以自己实现也可以利用springboot的slueth实现。第二个问题,就需要观察dao接口得特点,找出它们的共性, 现在大部分项目都是使用mybatis-plus作为持久层实现,先看一个普通的示例
//用户实体对象
@TableName("account")
public class Account implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String username;
}
// 用户接口
public interface AccountMapper extends BaseMapper<Account> {
}
特点:
思路:
有了这两点就可以实现
MybatisPlusMethodInterceptor 实现MethodInterceptor接口,作为方法增强的入口.
public class MybatisPlusMethodInterceptor extends DataLogAspectSupport implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
// 非BaseMapper类型跳过增强
if (!(invocation.getThis() instanceof BaseMapper)) {
return invocation.proceed();
}
Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
return invoke(invocation.getThis(), targetClass, invocation.getMethod(), invocation.getArguments(), invocation::proceed);
}
}
DataLogAspectSupport封装实现日志记录逻辑
由于mybatis-plus的mapper接口都是实现BaseMapper
public interface AccountMapper extends BaseMapper<Account> {
}
利用这个特点可以实现统一逻辑处理. update和delete操作,结合反射获取mybatis-plus注解@TableId
@TableName("account")
public class Account {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String username;
//setter/getter省略
}
即可获取当前记录的主键,从而自动查询操作前数据,生成比对信息
public class DataLogAspectSupport { private static final Logger LOGGER = LoggerFactory.getLogger(DataLogAspectSupport.class); public Object invoke(Object target, Class<?> targetClass, Method method, Object[] args, final InvocationCallback invocation) throws Throwable { LogInfo logInfo = this.recordLog(target, targetClass, method, args); long startTime = System.currentTimeMillis(); Object result = null; try { result = invocation.proceedWithLog(); } catch (Throwable t) { if (logInfo != null) { logInfo.setException(ExceptionUtils.getRootCause(t).getMessage()); } } finally { long timeout = System.currentTimeMillis() - startTime; logInfo.setTimeout(timeout); } LOGGER.info("[easy-log]{}:\n{}", logInfo.getMethod(), JSON.toJSONString(logInfo, true)); return result; } private LogInfo recordLog(Object target, Class<?> targetClass, Method method, Object[] args) { LogInfo logInfo = new LogInfo(); BaseMapper baseMapper = (BaseMapper) target; String methodName = ((Class)targetClass.getGenericInterfaces()[0]).getSimpleName() + "." + method.getName(); logInfo.setMethod(methodName); // TODO 后续支持更多方法 if (methodName.contains("updateById") || methodName.contains("deleteById")) { LOGGER.debug("[easy-log][{}] 执行数据变化分析--开始", methodName); Serializable primaryKey = this.getPrimaryKey(args[0]); LOGGER.debug("[easy-log][{}] key:[{}] current:{}", methodName, primaryKey, JSON.toJSONString(args[0])); Object result = baseMapper.selectById(primaryKey); LOGGER.debug("[easy-log][{}] key:[{}] history:{}", methodName, primaryKey, JSON.toJSONString(result)); if (methodName.contains("updateById")){ try { List<CompareResult> compareResultList = this.compareTowObject(result, args[0]); logInfo.setDataSnapshot(JSON.toJSONString(compareResultList)); LOGGER.debug("[easy-log][{}] key:[{}] compareResult:" + JSON.toJSONString(compareResultList), methodName, primaryKey); for (CompareResult compareResult : compareResultList) { String report = compareResult.getFieldName() + "【" + compareResult.getFieldComment() + "】值:" + compareResult.getOldValue() + " => " + compareResult.getNewValue(); LOGGER.debug(report); } LOGGER.debug("[easy-log][{}] 执行数据变化分析--结束", methodName); } catch (IllegalAccessException e) { e.printStackTrace(); } } else { logInfo.setDataSnapshot(JSON.toJSONString(result)); } } return logInfo; } /** * 对比两个对象 * * @param oldObj 旧对象 * @param newObj 新对象 */ protected List<CompareResult> compareTowObject(Object oldObj, Object newObj) throws IllegalAccessException { List<CompareResult> list = new ArrayList<>(); //获取对象的class Class<?> clazz1 = oldObj.getClass(); Class<?> clazz2 = newObj.getClass(); //获取对象的属性列表 Field[] field1 = clazz1.getDeclaredFields(); Field[] field2 = clazz2.getDeclaredFields(); //遍历属性列表field1 for (int i = 0; i < field1.length; i++) { //遍历属性列表field2 for (int j = 0; j < field2.length; j++) { //如果field1[i]属性名与field2[j]属性名内容相同 if (field1[i].getName().equals(field2[j].getName())) { field1[i].setAccessible(true); field2[j].setAccessible(true); if (field2[j].get(newObj) == null) { continue; } //如果field1[i]属性值与field2[j]属性值内容不相同 if (!compareTwo(field1[i].get(oldObj), field2[j].get(newObj))) { CompareResult r = new CompareResult(); r.setFieldName(field1[i].getName()); r.setOldValue(field1[i].get(oldObj)); r.setNewValue(field2[j].get(newObj)); // TODO 获取属性名称功能暴露出去 // ApiModelProperty apiModelProperty = field1[i].getAnnotation(ApiModelProperty.class); // if (apiModelProperty != null) { // r.setFieldComment(apiModelProperty.value()); // } list.add(r); } break; } } } return list; } @FunctionalInterface protected interface InvocationCallback { @Nullable Object proceedWithLog() throws Throwable; } private Serializable getPrimaryKey(Object et) { // 反射获取实体类 Class<?> clazz = et.getClass(); // 不含有表名的实体就默认通过 if (!clazz.isAnnotationPresent(TableName.class)) { return (Serializable) et; } // 获取表名 TableName tableName = clazz.getAnnotation(TableName.class); String tbName = tableName.value(); if (StringUtils.isBlank(tbName)) { return null; } String pkName = null; String pkValue = null; // 获取实体所有字段 Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { // 设置些属性是可以访问的 field.setAccessible(true); if (field.isAnnotationPresent(TableId.class)) { // 获取主键 pkName = field.getName(); try { // 获取主键值 pkValue = field.get(et).toString(); } catch (Exception e) { pkValue = null; } } } return pkValue; } /** * 对比两个数据是否内容相同 * * @param object1,object2 * @return boolean类型 */ private 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; } }
public class LogInfo { /** 日志主键*/ // @TableId(type = IdType.UUID) private String logId; /** 日志类型*/ private String type; /** 日志标题*/ private String title; /** 日志摘要*/ private String description; /** 请求IP*/ private String ip; /** URI*/ private String requestUri; /** 请求方式*/ private String method; /** 提交参数*/ private String params; /** 异常*/ private String exception; /** 操作时间*/ private Date operateDate; /** 请求时长*/ private Long timeout; /** 用户登入名*/ private String loginName; /** requestID*/ private String requestId; /** 历史数据*/ private String dataSnapshot; /** 日志状态*/ private Integer status; //setter/getter省略 }
@ConditionalOnClass(BaseMapper.class) @Configuration public class MybatisPlusDataLogConfiguration { private static final Logger LOGGER = LoggerFactory.getLogger(MybatisPlusDataLogConfiguration.class); @Bean public AspectJExpressionPointcutAdvisor mybatisPlusMethodAdvisor(MybatisPlusMethodInterceptor interceptor) { AspectJExpressionPointcutAdvisor advisor = new AspectJExpressionPointcutAdvisor(); advisor.setExpression("execution(* com.easycode8.easylog.sample.mapper.*.*(..))"); advisor.setAdvice(interceptor); LOGGER.info("[easy-log]启动mybatis-plus操作数据比对"); return advisor; } @Bean public MybatisPlusMethodInterceptor mybatisPlusMethodInterceptor() { return new MybatisPlusMethodInterceptor(); } }
修改信息: 记录新旧值
删除信息: 记录历史数据
本文通过mybatis-plus作为持久层技术, 充分利用接口继承特点即注解特征结合aop技术,实现了零编码自动处理任意单表的数据变化功能。相比一些传统手写方案提升效率, 避免了入侵业务代码,保证解耦性,有了很大的改进。但是可以发现依然存在不足, 比如过于依赖一种持久层框架,mybatis-plus有注解有基类接口,那mybatis没有是不是就不能用。 还有些业务操作修改数据不是根据主键的进行的, 是非主键字段, 可能命中多条记录怎么处理,甚至是where多字段的怎么处理。 这些都是当前使用方案欠缺的地方, 需要继续挖掘共性特点。如何实现,后续文章继续介绍。
记录操作前数据,可以有多种实现做法,mybatis的拦截器也可以实现类似逻辑.
public class RecordInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; Object parameterObject = args[1]; BoundSql boundSql = ms.getBoundSql(parameterObject); String sql = boundSql.getSql(); SqlCommandType sqlCommandType = ms.getSqlCommandType(); if (sqlCommandType == SqlCommandType.DELETE || sqlCommandType == SqlCommandType.UPDATE) { // 记录信息的逻辑 String tableName = getTableNameFromSql(sql); // 获取删除或修改前的记录信息 List<Map<String, Object>> originalRecords = getOriginalRecords(tableName, parameterObject); // 将信息记录到日志文件或数据库中 recordLog(tableName, originalRecords); } return invocation.proceed(); } private String getTableNameFromSql(String sql) { // 根据 SQL 语句获取表名 // ... } private List<Map<String, Object>> getOriginalRecords(String tableName, Object parameterObject) { // 获取删除或修改前的记录信息 // ... } private void recordLog(String tableName, List<Map<String, Object>> originalRecords) { // 将信息记录到日志文件或数据库中 // ... } }
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.3.2.RELEASE</version> </dependency> <!--mybatis-plus--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.2</version> <optional>true</optional> </dependency> <!-- 处理异常工具 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.8.1</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.79</version> </dependency>
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。