Springboot MongoDB 实现自定义主键

前言

MongoDB使用_id作为文档内部数据主键,正常情况下,我们也默认使用它作为文档内部数据的主键,它默认是个ObjectId对象。

如下形式:

Java中,它表现为字符串类型。

它在MongoDB中使用12字节的存储空间,每个字节两位十六进制数字,是一个24位的字符串。

ObjectId的生成方式如下图:

617bdbff69a05c6d954d2235
01234567891011
时间戳
机器
PID
计数器
  • 前4位是一个从标准纪元开始的时间戳,是一个int类别,只不过从十进制转换为了十六进制。这意味着这4个字节隐含了文档的创建时间,将会带来一些有用的属性。并且时间戳处于字符的最前面,同时意味着ObjectId大致会按照插入顺序进行排序,这对于某些方面起到很大作用,如作为索引提高搜索效率等等。使用时间戳还有一个好处是,某些客户端驱动可以通过ObjectId解析出该记录是何时插入的,这也解答了我们平时快速连续创 建多个ObjectId时,会发现前几位数字很少发现变化的现实,因为使用的是当前时间,很多用户担心要对服务器进行时间同步,其实这个时间戳的真实值并 不重要,只要其总不停增加就好。
  • 接下来的3个字节,是所在主机的唯一标识符,一般是机器主机名的散列值,这样就确保了不同主机生成不同的机器hash值,确保在分布式中不造成冲突,这也就是在同一台机器生成的ObjectId中间的字符串都是一模一样的原因。
  • 上面的机器字节是为了确保在不同机器产生的ObjectId不冲突,而PID就是为了在同一台机器不同的MongoDB进程产生了ObjectId不冲突。
  • 前面的9个字节是保证了一秒内不同机器不同进程生成ObjectId不冲突,最后的3个字节是一个自动增加的计数器,用来确保在同一秒内产生的ObjectId也不会冲突,允许256的3次方等于16777216条记录的唯一性。

由于MongoDB一开始设计就是用来做分布式数据库的,因此相比于关系型数据库(如Mysql)里的自增id,或者一些场景的UUID,其不仅能保证数据处理速度、唯一性要求,而且通过ObjectId本身,也能提供一些有用的信息。

主键类型随机性占用空间并发支持自身意义(自身包含的信息)
自增Id
UUID
ObjectId

但是,总有一些特殊情况,我们可能需要在MongoDB中使用自增id或者UUID,这种情况,我们应该如何处理呢?

正文

MongoDB本身并没有提供自增主键或者UUID等的生成逻辑,但是我们可以通过程序,为指定的字段赋值。

以下代码基于 springboot+mongodb

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
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

我们先来看下MongoDB自身ObjectId的处理方式。

比如对于下面的类。

1
2
3
4
5
6
7
8
9
10
11
@Data
@Document(collection = "user")
public class User {
@Id
private String _id;
private String userId;
private Long longId;
private String userName;
private String userSex;
private String birthday;
}

其对应MongoDB中的user集合。

_id表示文档的唯一id,即ObjectId,我们为其添加@Id注解,新增文档时,就会生成一个唯一的文档id。这是Spring data通用注解,当然我们也可以直接指定@MongoDB注解,而且@MongoDB注解可以自定义Id的转换形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
@Document(collection = "user")
public class User {

@MongoId(FieldType.OBJECT_ID)
//默认FieldType.IMPLICIT,不填的话会根据_id的类型进行转换,转换方法:
//org.springframework.data.mongodb.core.convert.MongoConverter.convertId(Object, Class).
private String _id;
private String userId;
private Long longId;
private String userName;
private String userSex;
private String birthday;
}

可以看到,对于ObjectId,我们只需要添加注解即可以生成,无需过多关心。

这儿要注意FieldType一旦确定了类型不要随便改哦,否则可能出现下面这样的问题。

这两个文档_id其实是不一样的,一个是用FieldType.OBJECT_ID生成的,一个是用FieldType.STRING生成的,我们改了FieldType类型,进行更新的时候,就会出现两个看起来很相似的文档了……

再回到我们正题,假设我们想要一个唯一的userId(UUID)和一个唯一的longId(自增id)字段呢?

对于userId或者longId,当然我们可以在每次新增文档时,手动去进行设置,大致如下:

1
2
3
4
5
6
User user = new User();
......
user.setUserId(UUID.randomUUID());//UUID.randomUUID() 生成一个UUID
user.setLongId(getNextLongId());//getNextLongId()通过某种方法获取自增longId
......
userDao.save(user);

这种操作并不方便,并且可能会有遗漏。

其实我们也可以和上面提到的@Id注解一样,自定义两个注解,一个代表自增主键,一个代表UUID

然后通过获取注解并进行一些操作,为两个字段赋值。

我们来看下:

1
2
3
4
5
6
7
8
9
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface IncKey {
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface UUIDKey {
}

我们定义了两个注解@IncKey@UUIDKey,一个代表自增主键,一个代表UUID

要解析它们并且进行处理,需要在一个公共点,即应该在保存文档之前,所有文档的保存都应该过这个点。

spring-data-mongo中恰好给我们提供了这样一个类,它在常用事件的生命周期内提供一些方法,我们来看下。

AbstractMongoEventListener就是可以监听常用事件生命周期的一个Abstract类,其结构如下:

  • onBeforeConvert:在将对象转换为Document之前,在MongoTemplate中调用insertinsertListsave操作。

  • onBeforeSave:在数据库中插入或保存Document之前,在MongoTemplate中调用insertinsertListsave操作。

  • onAfterSave:在数据库中插入或保存Document之后,在MongoTemplate 中调用insertinsertListsave操作。

  • onAfterLoad:在从数据库检索文档之后,在MongoTemplatefindfindAndRemovefindOnegetCollection方法中调用。

  • onAfterConvert:在从数据库中检索到文档并转换为POJO之后,在MongoTemplatefindfindAndRemovefindOnegetCollection方法中调用。

  • onAfterDelete:在从数据库中删除一个或一组文档之后,在MongoTemplateremove方法中调用。

  • onBeforeDelete:在从数据库中删除一个或一组文档之前,在MongoTemplateremove方法中调用。

对于我们上述两个字段的赋值,我们可以在对象被转为Documnet之前进行设置,也就是重写onBeforeConvert方法。

方法中我们需要通过反射拿到有我们自定义注解的字段,然后对字段值进行赋值操作。

对于UUID的生成,还是比较简单的。然而如何生成自增主键呢?

我们先来看下整体代码:

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
@Component
public class MongoSaveEventListener extends AbstractMongoEventListener<Object> {

@Autowired
private MongoTemplate mongoTemplate;

@Override
public void onBeforeConvert(BeforeConvertEvent<Object> event) {
final Object source = event.getSource();
if (source != null) {
ReflectionUtils.doWithFields(source.getClass(), field -> {
ReflectionUtils.makeAccessible(field);
// 如果字段添加了我们自定义的IncKey注解
if (field.isAnnotationPresent(IncKey.class)) {
Object value = field.get(source);
if(value == null){
// 通过反射设置自增ID
field.set(source, getNextId(source.getClass().getSimpleName()));
}
}else if(field.isAnnotationPresent(UUIDKey.class)){
//如果字段添加了我们定义的UUIDKey注解 (两个注解不能同时添加,默认自增主键优先级高即可)
Object value = field.get(source);
if(value == null){
// 通过反射设置自增ID
field.set(source,randomUUID());
}
}

});
}
}

/**
* 获取下一个自增id
* @param collName
* @return
*/
private Long getNextId(String collName) {
Query query = new Query(Criteria.where("collName").is(collName));
Update update = new Update();
update.inc("seqId", 1); //每次自增1
FindAndModifyOptions options = new FindAndModifyOptions();
options.upsert(true); //更新
options.returnNew(true);//返回更新后的文档
//操作Sequence表,对其seqId加一并且返回最终值
Sequence seq = mongoTemplate.findAndModify(query, update, options, Sequence.class);
return seq.getSeqId();
}

/**
* 判断字符串是否为空
* @param cs
* @return
*/
private static boolean isEmpty(CharSequence cs) {
return cs == null || cs.length() == 0;
}


/**
* 生成uuid
* @return
*/
private static String randomUUID() {
return UUID.randomUUID().toString()
.replace("-", "");
}

}

可以看到,对于自增主键,我们使用了getNextId来获取,需要传入collName,这是针对于每个集合,自增主键都是从0开始单独计数。

我们创建了一个sequence集合用来保存其它需要自增主键的集合当前的自增主键id值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
@Document(collection = "sequence")
public class Sequence {

@Id
private String _id;

/**
* 集合名称
*/
private String collName;

/**
* 唯一自增id
*/
private Long seqId;
}

使用了mongoTemplatefindAndModify操作,该操作具有原子性,可以保证并发情况下的数据正确性。

我们写上Dao层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Repository
public class UserDao {
@Autowired
private MongoTemplate mongoTemplate;

public User save(User user) {
mongoTemplate.save(user);
return user;
}

public User getByLongId(Long longId){
Criteria criteria = Criteria.where("longId").is(longId);
Query query = new Query(criteria);
return mongoTemplate.findOne(query,User.class);
}
}

然后用测试类进行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
@Document(collection = "user")
public class User {

@Id
private String _id;
@UUIDKey
private String userId;
@IncKey
private Long longId;
private String userName;
private String userSex;
private String birthday;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SpringBootTest
class DemoApplicationTests {

@Autowired
private UserDao userDao;

@Test
void contextLoads() {
User user = new User();
user.setUserName("zwt");
user.setUserSex("男");
user.setBirthday("1991-09-09");
userDao.save(user);
}

}

运行后可以看到生成的Document携带 longIduserId

我们把User上的两个自定义注解去掉,运行就会发现新生成的文档不会携带这两个字段。

由于我们判断的是有自定义注解时,该字段为空才当作新增处理,因此如果我们对文档进行修改或删除(非此两个字段),并不会影响到它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@SpringBootTest
class DemoApplicationTests {

@Autowired
private UserDao userDao;

@Test
void contextLoads() {
// User user = new User();
// user.setUserName("zwt");
// user.setUserSex("男");
// user.setBirthday("1991-09-09");
User user = userDao.getByLongId(4L);
user.setUserName("112121212");
userDao.save(user);
}

}

最后demo项目整体结构如下图,上述代码既是该demo的全部代码。

总结

本文我们学到了如何操作mongoTemplate生成自定义主键的问题。

其实mongodb大部分场景不需要我们去手动生成主键,既不方便也不实用。

但通过这篇文章,我们还可以了解到一些方法,如想在文档保存前进行其他一些操作,那么根据上述方法,也能轻松应对处理。

参考资料




-------------文章结束啦 ~\(≧▽≦)/~ 感谢您的阅读-------------

您的支持就是我创作的动力!

欢迎关注我的其它发布渠道