关于不使用外键(或软删除)的情况下如何保证关联数据完整性的思考
引言
最近在修改公司项目中由于用户数据软删除引发的一系列问题时,对于外键的使用也进行了一波思考。
相信看过阿里开发手册的朋友们应该看到过这一段话
因此特意百度了一番,众说纷纭。当然今天不是为了探讨外键的使用与否,今天我思考的问题是
在不使用外键的情况下,如何方便地保证关联数据的完整性
或者暂不提使不使用外键
在关联数据软删除的情况下,如何方便地保证关联数据的完整性
至于为什么会有对于这个问题的思考呢,最近修改公司项目中由于用户软删除引发的一系列数据完整性问题时,开始了对于这方面的思考。其实公司项目使用了外键,相关联数据如果被删除会抛出DataIntegrityViolationException异常,因此在不考虑是否应该使用外键的情况下,这是可以解决问题的。问题是我们的用户数据是软删除,无法使用外键进行统一的关联校验,对于数据完整性要求比较高的场景下,这样肯定是不可以的。
至于说在删除的地方一个一个校验使用的地方,可不可以呢,当然是可以,但是肯定不是最好的方法。不仅代码冗余,而且这样我岂不是每增加一个使用的地方就要回过头去修改一遍删除校验的代码。
因此针对这个问题,我提出一点自己的想法,如果各位有更好的方法希望可以告诉我~
我提出的方案是使用AOP 注解的方式,在相关联的字表的DO实体的字段上加上关联注解(LinkedField),说明是关联的哪张表中的哪个字段,并在spring容器启动的过程中就将这些关联关系同步到redis中,然后在删除方法上也加上一个注解(LinkedSource),说明需要删除哪张表中哪个字段,然后在AOP的增强中获取当前主表字段有多少子表关联数据,就可以实现了。其实,换一句话说,我把数据库中的外键关系copy到了应用层中。
正文
一、创建关联关系注解LinkedField
LinkedField注解,你可以理解为就是应用层的外键,它说明了主表和子表中对应的字段关联关系。它是一个Field类型的注解,说明是加载属性上的。
这个注解虽然是加在子表的DO的子段上的,实际对子表的增删改查没有影响,只是找一个地方定义一个关联关系,当然我这里认为“您将会为每一个表都创建一个对应的DO”
/**
* 关联数据注解
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface LinkedField {
/**
* 主表
*/
String sourceTable();
/**
* 主表中关联字段
*/
String sourceField();
/**
* 子表
*/
String linkedTable();
/**
* 子表中关联字段
*/
String linkedField();
}
二、创建数据库表一一对应的DO
假设我有一张sys_user表作为主表,另外有四张表,分别是教师表(teacher)、学生表(student)、成绩表(score)、男生表(boy),四个主表中都有个user_id字段对应用户表中的主键id,我现在要实现,当该用户数据在四个子表中有关联数据,不能删除。
创建四个子表的DO(对应的建表语句文中就不贴出来了)
1、teacher
@Component
@Data
public class Teacher {
private Long id;
private String name;
@LinkedField(sourceTable = "sys_user",sourceField = "id",linkedTable = "teacher",linkedField = "user_id")
private Long userId;
}
2、student
@Component
@Data
public class Student {
private Long id;
private String name;
@LinkedField(sourceTable = "sys_user",sourceField = "id",linkedTable = "student",linkedField = "user_id")
private Long userId;
}
3、分数表
@Component
@Data
public class Score {
private Long id;
private String name;
@LinkedField(sourceTable = "sys_user", sourceField = "id", linkedTable = "score", linkedField = "user_id")
private Long createUser;
}
4、男生表
@Component
@Data
public class Boy {
private Long id;
private String name;
@LinkedField(sourceTable = "sys_user",sourceField = "id",linkedTable = "boy",linkedField = "user_id")
private Long userId;
}
三、Spring启动过程中将所有关联关系同步到 redis中
想要在spring启动过程中假如自定义处理可以通过实现BeanPostProcessor,看一下这个接口都有哪些方法。有两个类似的方法,方法参数中bean是spring启动过程中初始化的所有的类,在这里就可以通过反射拿到类的属性,同样的也就可以拿到属性上面LinkedField注解的内容。将所有的关联关系同步到redis中,方便后续做切面的时候读取。
public interface BeanPostProcessor {
@Nullable
default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
@Nullable
default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
//bean是spring启动过程中初始化的所有的类,在这里就可以通过反射拿到类的属性,同样的也就可以拿到属性上面LinkedField注解的内容
return bean;
}
}
看一下加入了自定义处理逻辑的代码
/**
* spring启动时初始化关联关系到redis中
*/
@Component
public class LinkedFieldAnnotationStartUpListener implements BeanPostProcessor {
@Autowired
private RedisTemplate redisTemplate;
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
//当前redis容器中存储的所有关联记录
List<LinkedFieldEntity> result = redisTemplate.opsForList().range("linkedField", 0, -1);
Field field;
Field[] fields = bean.getClass().getDeclaredFields();
for (int i = 0; i < fields.length; i++) {
//关闭反射安全检查,达到提升反射速度的目的
fields[i].setAccessible(true);
}
List<LinkedFieldEntity> linkedFieldEntityList = new ArrayList<>();
for (int i = 0; i < fields.length; i++) {
try {
//反射拿到所有bean的字段
field = bean.getClass().getDeclaredField(fields[i].getName());
//拿到加了LinkedField注解的字段注解信息
LinkedField linkedField = field.getAnnotation(LinkedField.class);
if (linkedField != null) {
System.out.println("\r\nspring容器启动过程中获取加了LinkedField注解的字段,注解参数:");
System.out.println("sourceTable: " + linkedField.sourceTable()
+ " ;sourceField: " + linkedField.sourceField()
+ " ;linkedTable: " + linkedField.linkedTable()
+ " ;linkedField: " + linkedField.linkedField());
LinkedFieldEntity linkedFieldEntity = new LinkedFieldEntity();
linkedFieldEntity.setSourceTable(linkedField.sourceTable());
linkedFieldEntity.setSourceField(linkedField.sourceField());
linkedFieldEntity.setLinkedTable(linkedField.linkedTable());
linkedFieldEntity.setLinkedField(linkedField.linkedField());
//判断当前字表是否已经同步到redis容器中,避免重复存储
List<LinkedFieldEntity> matchedList = result.stream().filter(item -> (
item.getSourceTable().equals(linkedFieldEntity.getSourceTable())
&& item.getSourceField().equals(linkedFieldEntity.getSourceField())
&& item.getLinkedTable().equals(linkedFieldEntity.getLinkedTable())
&& item.getLinkedField().equals(linkedFieldEntity.getLinkedField())
)).collect(Collectors.toList());
if (matchedList == null || matchedList.size() == 0) {
//新创建的关联关系才存储到redis中
linkedFieldEntityList.add(linkedFieldEntity);
}
}
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
if (linkedFieldEntityList.size() > 0) {
//将新创建的关联记录同步到redis中
redisTemplate.opsForList().rightPushAll("linkedField", linkedFieldEntityList);
}
return bean;
}
}
四、在删除(不使用外键,或软删除)的方法上做切点
1、删除方法的注解
LinkedSource 注解是METHOD类型注解。table参数说明是删除操作的是哪张表,field表明操作的是哪个字段。
/**
* 目标关联数据注解
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LinkedSource {
String table();
String field();
}
2、切面
这个切面的作用是根据删除方法上注解定义的正在操作的表,找出事先定义好的子表关联关系,然后在切面内统一查询是否有关联数据,如果有,进行后续处理,一般是抛出自定义异常,转化成json格式数据提示前台,我这里就简单打印一下。
@Component
@Aspect
public class LinkedSourceImpl {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private CountService countService;
/**
* 切点
*/
@Pointcut("@annotation(com.datacheck.demo.anno.LinkedSource)")
public void linkedSourcePointCut() {
}
/**
* 环绕
*/
@Around("linkedSourcePointCut()")
public void linkedFieldAround(ProceedingJoinPoint point) {
try {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
LinkedSource linkedSource = method.getAnnotation(LinkedSource.class);
if (linkedSource != null) {
//注解上的参数
//删除的字段
String field = linkedSource.field();
//删除字段对应的的表名
String table = linkedSource.table();
//从环绕增强中取出方法参数
String targetFieldValue = point.getArgs()[0]+"";
System.out.println("LinkedSource.table:" + table + ";field:" + field + ";targetFieldValue:" + targetFieldValue);
//从redis中取出全部数据
List<LinkedFieldEntity> result = redisTemplate.opsForList().range("linkedField", 0, -1);
System.out.println("关联关系 全部(条数): " + result.size());
System.out.println("关联关系 全部(数据): " + result.toString());
//筛选出当前删除的字段相关联的子表
List<LinkedFieldEntity> operatingTableList = result.stream().filter(item->(item.getSourceTable().equals(table)&&item.getSourceField().equals(field))).collect(Collectors.toList());
System.out.println("关联关系 当前操作相关的(条数): " + operatingTableList.size());
System.out.println("关联关系 当前操作相关的(数据): " + operatingTableList.toString());
//遍历查询子表中是否有关联记录
for (LinkedFieldEntity entity : operatingTableList) {
//动态拼接表名查询,查询关联字表中是否有关联的数据
Integer count = countService.count(entity.getLinkedTable(), entity.getLinkedField(), targetFieldValue);
System.out.println("\r\n"+entity.getLinkedTable()+"表中关联数据条数:"+count);
if (count != null && count > 0) {
System.out.println("注意:"+entity.getLinkedTable()+"表有关联数据");
}
}
}
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
}
五、效果演示
1、启动过程中日志
我总共加了5个LinkedField注解,其中有四个都是关联的用户表中的主键id
2、删除用户
演示删除之前还得做两件事
(1)编写controller接口
增加删除方法,作用是根据用户id删除用户
@RestController
public class UserController {
@LinkedSource( table = "sys_user",field = "id")
@DeleteMapping(value = "/deleteById")
public JsonResult deleteById(Integer id) {
return ResultTool.success("测试删除接口");
}
}
(2)增加表数据
增加几条关联数据,如下(我在四个表中加了3条关联数据)
(3)删除关联数据
六、结束语
这只是我站在一个菜鸟的角度,提出的一种方案,如果大佬们有更好的见解希望告诉我~~
上一篇: 浅谈分布式事务
下一篇: 我有个事情我移植想不明白