一、背景与需求
在日常应用开发中,数据变更记录是一个常见需求。我们需要记录业务数据的每一次变更,包括:
- 字段级别的变更记录
- 变更前后的值
- 变更人、变更时间
- 支持insert/update/delete操作
- 支持单条和批量操作
- 确保数据一致性和准确性
二、整体架构设计
2.1 核心组件
┌─────────────────────────────────────┐
│ controller/service层 │
└───────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ mapper接口(带注解) │
│ @datachangelog(businesstype) │
└───────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ mybatis executor.update() │
│ 拦截器拦截点 │
└───────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ improveddatachangeinterceptor │
│ ┌─────────────────────────────┐ │
│ │ 1. 获取注解/判断是否需要记录 │ │
│ │ 2. 查询原始数据 │ │
│ │ 3. 执行sql操作 │ │
│ │ 4. 事务提交后查询新数据 │ │
│ │ 5. 对比差异并保存记录 │ │
│ └─────────────────────────────┘ │
└───────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ sys_data_change_log │
│ 数据变更记录表 │
└─────────────────────────────────────┘2.2 数据表设计
create table sys_data_change_log (
id bigint(20) not null auto_increment comment '主键id',
business_type varchar(50) not null comment '业务类型',
business_id varchar(50) not null comment '业务数据id',
field_name varchar(50) not null comment '字段名称',
field_comment varchar(100) default null comment '字段注释',
old_value text comment '变更前值',
new_value text comment '变更后值',
change_type varchar(20) default 'update' comment '变更类型',
change_by varchar(50) not null comment '变更人',
change_time datetime not null comment '变更时间',
primary key (id),
key idx_business (business_type, business_id),
key idx_change_time (change_time)
) engine=innodb default charset=utf8mb4 comment='数据变更记录表';三、核心代码实现
3.1 自定义注解
package com.demo.framework.aspectj.lang.annotation;
import java.lang.annotation.*;
/**
* 数据变更记录注解
* 标记在mapper方法上,表示该方法需要记录数据变更
*/
@target({elementtype.method})
@retention(retentionpolicy.runtime)
@documented
public @interface datachangelog {
/**
* 业务类型
*/
string businesstype() default "";
/**
* 是否记录详情(字段级别)
*/
boolean detail() default true;
/**
* 忽略的字段
*/
string[] ignorefields() default {"createtime", "updatetime", "createby", "updateby"};
}3.2 数据变更记录实体
package com.demo.system.domain;
import com.demo.common.annotation.excel;
import java.util.date;
/**
* 数据变更记录对象 sys_data_change_log
*/
public class sysdatachangelog {
private static final long serialversionuid = 1l;
/** 主键id */
private long id;
/** 业务类型 */
@excel(name = "业务类型")
private string businesstype;
/** 业务数据id */
@excel(name = "业务数据id")
private string businessid;
/** 字段名称 */
@excel(name = "字段名称")
private string fieldname;
/** 字段注释 */
@excel(name = "字段注释")
private string fieldcomment;
/** 变更前值 */
@excel(name = "变更前值")
private string oldvalue;
/** 变更后值 */
@excel(name = "变更后值")
private string newvalue;
/** 变更类型 */
@excel(name = "变更类型")
private string changetype;
/** 变更人 */
@excel(name = "变更人")
private string changeby;
/** 变更时间 */
@excel(name = "变更时间", dateformat = "yyyy-mm-dd hh:mm:ss")
private date changetime;
// getters and setters...
}
四、数据提交后再记录日志的方案
4.1 事务同步机制
使用spring的transactionsynchronization实现事务提交后执行记录操作:
/**
* 注册事务提交后的任务
* 核心:确保数据真正写入数据库后才记录变更日志
*/
private void registeraftercommittask(runnable task) {
if (transactionsynchronizationmanager.issynchronizationactive()) {
// 事务中:注册事务同步器,事务提交后执行
transactionsynchronizationmanager.registersynchronization(
new transactionsynchronization() {
@override
public void aftercommit() {
task.run();
}
@override
public void aftercompletion(int status) {
if (status == status_rolled_back) {
log.debug("事务回滚,不记录变更");
}
}
}
);
} else {
// 无事务:直接执行
log.debug("无事务环境,直接执行");
task.run();
}
}
4.2 更新操作处理流程
/**
* 处理更新操作
* 三步走策略:
* 1. 更新前查询原始数据
* 2. 执行更新操作
* 3. 事务提交后查询新数据并对比记录
*/
private object handleupdate(invocation invocation, mappedstatement ms,
object parameter, datachangelog datachangelog) throws throwable {
log.debug("处理更新操作,业务类型:{}", datachangelog.businesstype());
// 1. 更新前查询原始数据
object beforedata = querydatabyid(parameter, ms);
log.debug("更新前数据:{}", json.tojsonstring(beforedata));
// 2. 缓存操作信息
operationinfo opinfo = new operationinfo();
opinfo.setbusinesstype(datachangelog.businesstype());
opinfo.setignorefields(datachangelog.ignorefields());
opinfo.setparameter(parameter);
opinfo.setsqlcommandtype(sqlcommandtype.update);
opinfo.setmapperid(ms.getid());
opinfo.setbeforedata(beforedata);
operation_info_thread_local.set(opinfo);
// 3. 执行更新操作
object result = invocation.proceed();
// 4. 如果更新成功,在事务提交后处理变更记录
if (integer.parseint(result.tostring()) > 0) {
registeraftercommittask(() -> processupdateaftercommit(opinfo));
}
return result;
}
/**
* 事务提交后处理更新操作
*/
private void processupdateaftercommit(operationinfo opinfo) {
try {
// 1. 更新后查询最新数据
object afterdata = querydatabyid(opinfo.getparameter(), opinfo.getmapperid());
log.debug("更新后数据:{}", json.tojsonstring(afterdata));
if (opinfo.getbeforedata() != null && afterdata != null) {
// 2. 对比数据差异
list<sysdatachangelog> changelogs = comparedata(
opinfo.getbeforedata(),
afterdata,
opinfo.getbusinesstype(),
opinfo.getignorefields()
);
// 3. 批量保存变更记录
if (!changelogs.isempty()) {
datachangelogservice.insertbatch(changelogs);
log.info("保存{}条更新变更记录", changelogs.size());
}
}
} catch (exception e) {
log.error("处理更新后变更记录失败", e);
}
}
4.3 数据对比工具方法
/**
* 对比数据差异
* 支持日期、数字类型的精确比较
*/
private list<sysdatachangelog> comparedata(object beforedata, object afterdata,
string businesstype, string[] ignorefields) {
list<sysdatachangelog> changelogs = new arraylist<>();
try {
map<string, object> beforemap = converttomap(beforedata);
map<string, object> aftermap = converttomap(afterdata);
string businessid = extractbusinessid(aftermap);
// 遍历更新后的数据
for (map.entry<string, object> entry : aftermap.entryset()) {
string fieldname = entry.getkey();
// 忽略指定字段
if (shouldignore(fieldname, ignorefields)) {
continue;
}
object beforevalue = beforemap.get(fieldname);
object aftervalue = entry.getvalue();
// 比较值是否变化
if (!isvalueequals(beforevalue, aftervalue)) {
sysdatachangelog changelog = createchangelog(
businesstype, businessid, fieldname,
getfieldcomment(afterdata, fieldname),
beforevalue, aftervalue, "update"
);
changelogs.add(changelog);
}
}
} catch (exception e) {
log.error("对比数据差异失败", e);
}
return changelogs;
}
/**
* 判断两个值是否相等(处理各种数据类型)
*/
private boolean isvalueequals(object v1, object v2) {
if (v1 == null && v2 == null) return true;
if (v1 == null || v2 == null) return false;
// 处理日期类型
if (v1 instanceof date && v2 instanceof date) {
return ((date) v1).gettime() == ((date) v2).gettime();
}
// 处理数字类型(避免精度问题)
if (v1 instanceof number && v2 instanceof number) {
return new bigdecimal(v1.tostring())
.compareto(new bigdecimal(v2.tostring())) == 0;
}
return v1.equals(v2);
}
五、拦截器未注册到spring容器的解决方案
5.1 问题分析
在实际项目中,经常遇到拦截器配置了@component注解,但mybatis并没有自动注册拦截器的情况。主要原因:
- mybatis自动配置顺序问题:拦截器注册时机过早
- 多数据源配置:需要为每个sqlsessionfactory手动注册
- 自定义mybatis配置:覆盖了默认配置
5.2 解决方案一:手动注册拦截器(推荐)
package com.demo.framework.config;
import com.demo.framework.interceptor.improveddatachangeinterceptor;
import org.apache.ibatis.plugin.interceptor;
import org.apache.ibatis.session.sqlsessionfactory;
import org.springframework.beans.factory.annotation.autowired;
import org.springframework.context.annotation.configuration;
import org.springframework.context.annotation.lazy;
import javax.annotation.postconstruct;
import java.util.list;
/**
* 拦截器手动注册配置
* 解决拦截器未自动注册到mybatis的问题
*/
@configuration
public class interceptormanualconfig {
private static final org.slf4j.logger log =
org.slf4j.loggerfactory.getlogger(interceptormanualconfig.class);
@autowired
private list<sqlsessionfactory> sqlsessionfactories;
@autowired
@lazy // 避免循环依赖
private improveddatachangeinterceptor datachangeinterceptor;
@postconstruct
public void manualregisterinterceptor() {
log.info("========== 开始手动注册数据变更拦截器 ==========");
log.info("发现 {} 个sqlsessionfactory", sqlsessionfactories.size());
for (int i = 0; i < sqlsessionfactories.size(); i++) {
sqlsessionfactory sqlsessionfactory = sqlsessionfactories.get(i);
org.apache.ibatis.session.configuration configuration =
sqlsessionfactory.getconfiguration();
// 检查是否已经注册
boolean alreadyregistered = false;
for (interceptor interceptor : configuration.getinterceptors()) {
if (interceptor instanceof improveddatachangeinterceptor) {
alreadyregistered = true;
log.info("sqlsessionfactory[{}] 拦截器已存在,跳过注册", i);
break;
}
}
if (!alreadyregistered) {
log.info("注册拦截器到 sqlsessionfactory[{}]: {}",
i, sqlsessionfactory);
configuration.addinterceptor(datachangeinterceptor);
}
}
// 验证注册结果
verifyinterceptorregistration();
log.info("========== 拦截器手动注册完成 ==========");
}
/**
* 验证拦截器注册结果
*/
private void verifyinterceptorregistration() {
for (int i = 0; i < sqlsessionfactories.size(); i++) {
sqlsessionfactory sqlsessionfactory = sqlsessionfactories.get(i);
list<interceptor> interceptors =
sqlsessionfactory.getconfiguration().getinterceptors();
log.info("sqlsessionfactory[{}] 已注册拦截器数量: {}", i, interceptors.size());
for (interceptor interceptor : interceptors) {
log.info(" - {}", interceptor.getclass().getsimplename());
}
}
}
}
5.3 解决方案二:使用mybatis配置类
package com.demo.framework.config;
import com.demo.framework.interceptor.improveddatachangeinterceptor;
import org.apache.ibatis.plugin.interceptor;
import org.apache.ibatis.session.sqlsessionfactory;
import org.mybatis.spring.sqlsessionfactorybean;
import org.mybatis.spring.boot.autoconfigure.mybatisautoconfiguration;
import org.springframework.beans.factory.annotation.autowired;
import org.springframework.boot.autoconfigure.autoconfigureafter;
import org.springframework.context.applicationcontext;
import org.springframework.context.annotation.configuration;
import javax.annotation.postconstruct;
/**
* 通过mybatis配置类注册拦截器
*/
@configuration
@autoconfigureafter(mybatisautoconfiguration.class)
public class mybatisinterceptorconfig {
@autowired
private applicationcontext applicationcontext;
@autowired
private improveddatachangeinterceptor datachangeinterceptor;
@postconstruct
public void init() {
// 获取所有sqlsessionfactory
string[] sqlsessionfactorynames =
applicationcontext.getbeannamesfortype(sqlsessionfactory.class);
for (string name : sqlsessionfactorynames) {
sqlsessionfactory sqlsessionfactory =
applicationcontext.getbean(name, sqlsessionfactory.class);
// 获取当前的拦截器列表
org.apache.ibatis.session.configuration configuration =
sqlsessionfactory.getconfiguration();
// 添加拦截器(如果不存在)
boolean exists = false;
for (interceptor interceptor : configuration.getinterceptors()) {
if (interceptor instanceof improveddatachangeinterceptor) {
exists = true;
break;
}
}
if (!exists) {
configuration.addinterceptor(datachangeinterceptor);
}
}
}
}
5.4 解决方案三:验证拦截器是否注册
package com.demo.framework.config;
import org.apache.ibatis.plugin.interceptor;
import org.apache.ibatis.session.sqlsessionfactory;
import org.springframework.beans.factory.annotation.autowired;
import org.springframework.boot.commandlinerunner;
import org.springframework.stereotype.component;
import java.util.list;
/**
* 启动后验证拦截器注册情况
*/
@component
public class interceptorverifyrunner implements commandlinerunner {
private static final org.slf4j.logger log =
org.slf4j.loggerfactory.getlogger(interceptorverifyrunner.class);
@autowired
private list<sqlsessionfactory> sqlsessionfactories;
@override
public void run(string... args) throws exception {
log.info("========== 数据变更拦截器注册验证 ==========");
for (int i = 0; i < sqlsessionfactories.size(); i++) {
sqlsessionfactory factory = sqlsessionfactories.get(i);
list<interceptor> interceptors = factory.getconfiguration().getinterceptors();
boolean hasinterceptor = false;
for (interceptor interceptor : interceptors) {
string classname = interceptor.getclass().getname();
if (classname.contains("datachangeinterceptor")) {
hasinterceptor = true;
log.info("✅ sqlsessionfactory[{}] 已注册拦截器: {}",
i, classname);
}
}
if (!hasinterceptor) {
log.error("❌ sqlsessionfactory[{}] 未找到数据变更拦截器!", i);
}
}
log.info("==========================================");
}
}
5.5 调试用rest接口
package com.demo.web.controller.monitor;
import com.demo.common.core.controller.basecontroller;
import com.demo.common.core.domain.ajaxresult;
import org.apache.ibatis.plugin.interceptor;
import org.apache.ibatis.session.sqlsessionfactory;
import org.springframework.beans.factory.annotation.autowired;
import org.springframework.web.bind.annotation.getmapping;
import org.springframework.web.bind.annotation.requestmapping;
import org.springframework.web.bind.annotation.restcontroller;
import java.util.arraylist;
import java.util.hashmap;
import java.util.list;
import java.util.map;
/**
* 拦截器调试控制器
*/
@restcontroller
@requestmapping("/monitor/interceptor")
public class interceptordebugcontroller extends basecontroller {
@autowired
private list<sqlsessionfactory> sqlsessionfactories;
/**
* 查看拦截器注册状态
*/
@getmapping("/status")
public ajaxresult status() {
list<map<string, object>> result = new arraylist<>();
for (int i = 0; i < sqlsessionfactories.size(); i++) {
sqlsessionfactory factory = sqlsessionfactories.get(i);
map<string, object> factoryinfo = new hashmap<>();
factoryinfo.put("factoryindex", i);
factoryinfo.put("factoryname", factory.getclass().getsimplename());
list<interceptor> interceptors = factory.getconfiguration().getinterceptors();
list<string> interceptornames = new arraylist<>();
for (interceptor interceptor : interceptors) {
interceptornames.add(interceptor.getclass().getsimplename());
}
factoryinfo.put("interceptorcount", interceptors.size());
factoryinfo.put("interceptors", interceptornames);
factoryinfo.put("hasdatachangeinterceptor",
interceptornames.stream().anymatch(name -> name.contains("datachange")));
result.add(factoryinfo);
}
return success(result);
}
/**
* 测试拦截器是否工作
*/
@getmapping("/test")
public ajaxresult test() {
// 返回提示信息,实际测试需要调用mapper方法
return success("请调用实际的mapper方法测试拦截器,如:/system/user/update");
}
}
六、使用示例
6.1 mapper接口配置
package com.demo.system.mapper;
import com.demo.framework.aspectj.lang.annotation.datachangelog;
import com.demo.system.domain.sysuser;
import org.apache.ibatis.annotations.param;
import java.util.list;
public interface sysusermapper {
/**
* 修改用户信息
*/
@datachangelog(
businesstype = "user.update",
ignorefields = {"createtime", "updatetime", "password"}
)
public int updateuser(sysuser user);
/**
* 新增用户
*/
@datachangelog(businesstype = "user.insert")
public int insertuser(sysuser user);
/**
* 删除用户
*/
@datachangelog(businesstype = "user.delete")
public int deleteuserbyid(long userid);
/**
* 批量删除用户
*/
@datachangelog(businesstype = "user.batchdelete")
public int deleteuserbyids(@param("userids") list<long> userids);
/**
* 查询用户(用于获取变更前后的数据)
*/
public sysuser selectuserbyid(long userid);
}
6.2 service层调用
@service
public class sysuserserviceimpl implements isysuserservice {
@autowired
private sysusermapper usermapper;
@override
@transactional(rollbackfor = exception.class)
public int updateuser(sysuser user) {
// 直接调用mapper,拦截器会自动处理数据变更记录
// 记录会在事务提交后自动写入
return usermapper.updateuser(user);
}
}
七、常见问题与解决方案
7.1 拦截器未生效排查清单
| 问题 | 检查点 | 解决方案 |
|---|---|---|
| 拦截器类未扫描 | @component注解是否存在 | 添加注解或手动注册bean |
| mybatis未注册 | 查看configuration.getinterceptors() | 手动注册拦截器 |
| 多数据源问题 | 所有sqlsessionfactory都检查 | 为每个数据源注册拦截器 |
| 事务问题 | 事务提交前查询不到新数据 | 使用aftercommit回调 |
| 注解未识别 | mapper方法上是否有@datachangelog | 检查注解是否存在 |
7.2 性能优化建议
/**
* 批量操作优化
*/
private static final int batch_threshold = 100; // 批量阈值
// 分批保存变更记录
if (allchangelogs.size() > batch_threshold) {
list<list<sysdatachangelog>> partitions = partitionlist(allchangelogs, batch_threshold);
for (list<sysdatachangelog> partition : partitions) {
datachangelogservice.insertbatch(partition);
}
} else {
datachangelogservice.insertbatch(allchangelogs);
}
7.3 日志配置
# application.yml
logging:
level:
com.demo.framework.interceptor: debug
org.apache.ibatis: info八、总结
8.1 方案优势
- 数据一致性:事务提交后才记录日志,确保数据准确性
- 性能优化:异步处理变更记录,不影响主业务流程
- 字段级对比:精确记录每个字段的变化
- 易于集成:通过注解配置,对业务代码无侵入
- 支持批量:完善的批量操作处理机制
8.2 注意事项
- 查询方法必须存在:需要提供根据id查询数据的方法
- 事务管理:确保在事务环境中使用
- 大字段处理:考虑对blob等大字段的特殊处理
- 数据清理:定期清理历史变更记录
8.3 扩展建议
- 支持异步记录(使用消息队列)
- 支持变更记录查询和导出
- 支持自定义字段转换器
- 支持敏感数据脱敏
以上就是基于mybatis拦截器实现数据变更记录的技术方案的详细内容,更多关于mybatis拦截器数据变更记录的资料请关注代码网其它相关文章!
发表评论