多对多结构的通用处理

前言:本文基于 Mybatis-Plus Lombok 编写

在日常开发过程中,经常会遇到一些多对多的结构,如图:

多对多结构的通用处理

上图表示了一个经典的RBAC安全结构的一部分,用户和角色是一个多对多的关系。在关系型数据库中,通常的做法是创建一个链接键表,存储二者之间的链接关系。一个项目中往往存在多个多对多关系,也就有多个这样的链接键表,虽然表不一样,对应的外键字段也不一样,准备的Mapper也不一样,但是其操作不能说毫无差别吧,只能是一模一样——无非是查找对应的数据和修改对应的数据。

所以,我们可以类似 Mybatis-Plus 的做法,定义并实现通用接口,再让对应的Service继承即可。


一、设计接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
/**
* 链接键服务接口基类
*
* @param <A> 链接对象 A
* @param <B> 链接对象 B
* @param <LK> 链接键
* @author Amane64
*/
public interface LkService<A extends BaseEntity, B extends BaseEntity, LK extends BaseEntity & LkEntity> {

/**
* 创建链接
*
* @param aId A 主键
* @param bId B 主键
*/
void createLink(Long aId, Long bId);

/**
* 通过 B 主键,获取 A 列表
*
* @param bId B 主键
* @return A 列表
*/
List<A> getAList(Long bId);

/**
* 通过 A 主键,获取 B 列表
*
* @param aId A 主键
* @return B 列表
*/
List<B> getBList(Long aId);

/**
* 通过 B 主键,获取 A 主键列表
*
* @param bId B 主键
* @return A 列表
*/
List<Long> getAIdList(Long bId);

/**
* 通过 A 主键,获取 B 主键列表
*
* @param aId A 主键
* @return B 列表
*/
List<Long> getBIdList(Long aId);

/**
* 通过 A 主键,获取链接键列表
*
* @param aId A 主键
* @return 链接键列表
*/
List<LK> getLkListByAId(Long aId);

/**
* 通过 B 主键,获取链接键列表
*
* @param bId B 主键
* @return 链接键列表
*/
List<LK> getLkListByBId(Long bId);

/**
* 替换 A 链接的对象<br>
* 列表可以为空但不能为 null
*
* @param aId 目标 A 主键
* @param bIds 新的 B 主键列表
*/
void updateALinks(Long aId, List<Long> bIds);

/**
* 替换 B 链接的对象<br>
* 列表可以为空但不能为 null
*
* @param bId 目标 B 主键
* @param aIds 新的 A 主键列表
*/
void updateBLinks(Long bId, List<Long> aIds);
}

/**
* 数据结构基类
*
* @author Amane64
*/
@Data
public class BaseEntity implements Serializable {
@Serial
private static final long serialVersionUID = 1L;

/**
* 主键
*/
@TableId
private Long id;
}

/**
* 链接键适配接口
*
* @author Amane64
*/
public interface LkEntity {
Long getAId();

void setAId(Long aId);

Long getBId();

void setBId(Long bId);
}

二、通用实现

/(ㄒoㄒ)/ ~~ 比较头疼的是,这里使用了六个泛型,分别对应了链接键的两个端点实体类和链接键实体类,以及它们的Mapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
/**
* 链接键服务接口通用实现方法
*
* @param <A> 链接类 A
* @param <B> 链接类 B
* @param <LK> 链接键
* @param <AMapper> 链接类 A 的 Mapper
* @param <BMapper> 链接类 B 的 Mapper
* @param <LKMapper> 链接键 的 Mapper
* @author Amane64
*/
@Slf4j
@AllArgsConstructor
public class LkServiceImpl<A extends BaseEntity, B extends BaseEntity, LK extends BaseEntity & LkEntity,
AMapper extends BaseMapper<A>, BMapper extends BaseMapper<B>, LKMapper extends BaseMapper<LK>>
extends ServiceImpl<LKMapper, LK> implements LkService<A, B, LK>, IService<LK> {
private final AMapper aMapper;
private final BMapper bMapper;
private final Class<LK> clazz;

@Override
public void createLink(Long aId, Long bId) {
try {
LK lk = clazz.getDeclaredConstructor().newInstance();
lk.setAId(aId);
lk.setBId(bId);
save(lk);
} catch (InstantiationException e) {
throw new RuntimeException("链接键是抽象类或接口,或者链接键没有无参构造函数");
} catch (IllegalAccessException e) {
throw new RuntimeException("链接键构造函数私有化");
} catch (InvocationTargetException e) {
throw new RuntimeException("链接键构造函数内部异常");
} catch (NoSuchMethodException e) {
throw new RuntimeException("链接键没有符合要求的构造函数");
}
}

@Override
public List<A> getAList(Long bId) {
List<Long> aIdList = getAIdList(bId);
if (aIdList.isEmpty()) return new ArrayList<>();
return aMapper.selectByIds(aIdList);
}

@Override
public List<B> getBList(Long aId) {
List<Long> bIdList = getBIdList(aId);
if (bIdList.isEmpty()) return new ArrayList<>();
return bMapper.selectByIds(bIdList);
}

@Override
public List<Long> getAIdList(Long bId) {
List<LK> lkList = getLkListByBId(bId);
var aIds = new ArrayList<Long>();
lkList.forEach(lk -> aIds.add(lk.getAId()));
return aIds;
}

@Override
public List<Long> getBIdList(Long aId) {
List<LK> lkList = getLkListByAId(aId);
var bIds = new ArrayList<Long>();
lkList.forEach(lk -> bIds.add(lk.getBId()));
return bIds;
}

@Override
public List<LK> getLkListByAId(Long aId) {
return list(new QueryWrapper<LK>().eq(getFieldName("A"), aId));
}

@Override
public List<LK> getLkListByBId(Long bId) {
return list(new QueryWrapper<LK>().eq(getFieldName("B"), bId));
}

@Override
public void updateALinks(Long aId, List<Long> bIds) {
removeByIds(getLkListByAId(aId));
bIds.forEach(bId -> createLink(aId, bId));
}

@Override
public void updateBLinks(Long bId, List<Long> aIds) {
removeByIds(getLkListByBId(bId));
aIds.forEach(aId -> createLink(aId, bId));
}

/**
* 获取链接键字段名
*
* @param target A or B
* @return 字段名
*/
private String getFieldName(String target) {
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(LkField.class)) {
LkField lkField = field.getAnnotation(LkField.class);
if (lkField.position().equals(target)) {
String fieldName = lkField.fieldName();
if (fieldName.equals("no.set"))
fieldName = field.getName();
return LocalStringUtils.convertToUnderscore(fieldName);
}
}
}
throw new IllegalArgumentException("未使用 @LkField 注解标明链接端点字段");
}
}

使用例:

注意:需要在构造函数处注入并传参

1
2
3
4
5
6
7
8
@Service
public class LkUserRoleServiceImpl extends LkServiceImpl<User, Role, ILkUserRole, UserMapper, RoleMapper, LkUserRoleMapper> implements LkUserRoleService {

@Autowired
public LkUserRoleServiceImpl(UserMapper userMapper, RoleMapper roleMapper) {
super(userMapper, roleMapper, ILkUserRole.class);
}
}

三、解释部分重要的底层代码

  1. 创建链接记录:
    因为泛型在编译阶段会被擦除,所以不能直接用其创建对象,需要在构造函数注入的阶段,传入一个对应的Class对象用作生成。
    关键代码:
1
LK lk = clazz.getDeclaredConstructor().newInstance();
  1. @LkField注解解释与配置:
    在上面的实现中,编写了一个私有方法,解析了@LkField这个注解。我们注意到,对于链接键表,其端点字段均不同,如例子中的为user_idrole_id。为了解决这个问题,我们需要在实体类上对应的属性加上注解,标明哪些是端点字段。
    我们规定,fieldName为字段名,若未设置,默认解析类的属性名的下划线格式为字段名;position为对应位置,只传入AB,对应链接键的两个端点。
    关键代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 获取链接键字段名
*
* @param target A or B
* @return 字段名
*/
private String getFieldName(String target) {
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(LkField.class)) {
LkField lkField = field.getAnnotation(LkField.class);
if (lkField.position().equals(target)) {
String fieldName = lkField.fieldName();
if (fieldName.equals("no.set"))
fieldName = field.getName();
return LocalStringUtils.convertToUnderscore(fieldName);
}
}
}
throw new IllegalArgumentException("未使用 @LkField 注解标明链接端点字段");
}

注解代码:

1
2
3
4
5
6
@Target(ElementType.FIELD)  // 注解用于字段
@Retention(RetentionPolicy.RUNTIME) // 注解在运行时可见
public @interface LkField {
String fieldName() default "no.set";
String position();
}

使用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 用户角色连接键
*
* @TableName lk_user_role
*/
@EqualsAndHashCode(callSuper = true)
@TableName(value = "lk_user_role")
@Data
public class ILkUserRole extends BaseEntity implements LkEntity {
/**
* 用户主键
*/
@LkField(position = "A")
private Long userId;

/**
* 角色主键
*/
@LkField(position = "B")
private Long roleId;

@Override
public Long getAId() {
return userId;
}

@Override
public void setAId(Long aId) {
userId = aId;
}

@Override
public Long getBId() {
return roleId;
}

@Override
public void setBId(Long bId) {
roleId = bId;
}
}