SpringBoot中MongoDB的那些高级用法

不知道大家在工作项目中有没有使用MongoDB,在哪些场景中使用。MongoDB作为NoSQL数据库,不像SQL数据库那样,可以使用Mybatis框架。

如果需要在SpringBoot中使用MongoDB的话,我目前知道有三种方式,第一种是直接使用MongoDB官方的SDK,第二种是使用SpringJpa的方式,第三种是使用MongoTemplate。第二种在内部也是使用MongoTemplate的方式,只是封装了一些通用的CRUD操作,MongoTemplate也是对官方SDK的操作封装,其实本质上是没有什么区别的。

我在工作项目中,在云存储和IM系统中都使用了MongoDB,MongoTemplate和SpringJpa都有使用过,但是SpringJpa并不是特别好用,同时也踩过很多的坑,下面就来看看MongoDB在SpringBoot中的高级用法。

MongoDB注解

Spring Data MongoDB提供了很多的注解来简化简化操作,这些注解包括@Id, @Document, @Field等,这些注解可以在org.springframework.data.annotationorg.springframework.data.mongodb.core.mapping 包中找到。这些注解用于指示SpringBoot如何将Java对象映射到MongoDB的Document中。

  • @Id:该注解用于指定哪个字段被作为主键,可以配合@Field字段使用

    java 复制代码
    @Id
    @Field(value = "_id", targetType = FieldType.STRING)
    private String userId;// 将userId字段作为主键, 存储到Mongodb中的字段名为_id
  • @Field:该注解用于指定Document中字段的名称,默认情况下,Spring会将Java对象的字段的名作为Document中的字段名,如果你希望Document中的字段名和Java对象中的字段名不同,那么可以使用该注解进行指定。

  • @Document:用于将一个Java类映射到MongoDB的集合,默认情况下,Spring使用类名作为Collection名字,但是你也可以使用该注解来自定义Collection名字。

监听器

使用MongoTemplate进行CRUD操作时,会触发多个不同种类的监听器,我们可以创建不同类型的监听器,从而对查询条件,删除条件,Document映射等进行修改,日志记录,性能优化等。

上面这7个监听器,全部由org.springframework.data.mongodb.core.mapping.event.AbstractMongoEventListener#onApplicationEvent 方法触发,创建监听器也非常简单,只需要创建一个类继承自AbstractMongoEventListener ,然后根据所执行的CRUD操作,重写对应的方法,最后将该类放入Spring容器中就可以了,可以存在多个监听器。下面是监听器的一些基本用法:

设置主键值

MongoDB在插入时,如果没有指定_id字段的值,那么MongoDB会自动生成一个ObjectId类型的值作为_id 字段值,但是默认生成的是String类型。如果我们需要使用int,long类型作为_id字段类型,那么就必须在执行最终插入前手动进行设置。

假如又不想每次执行insert操作时,都手动设置对象中主键字段的值,那么可以在xcye.xyz.mongodb.demos.test.TestAbstractMongoEventListener#onBeforeConvert 方法中统一的对Java对象中主键字段进行赋值,比如使用uuid,雪花算法等自动生成一个唯一的主键值。

java 复制代码
@Override
    public void onBeforeConvert(BeforeConvertEvent<Object> event) {
        Object source = event.getSource();

        if (!(source instanceof MongoBaseDomain)) {
            return;
        }

        MongoBaseDomain<?> mongoBaseDomain = (MongoBaseDomain<?>) source;
        if (mongoBaseDomain.getId() != null) {
            return;
        }

        // 根据id字段的类型,如Long,String,Integer,自动生成一个唯一的主键值
        mongoBaseDomain.setId(idValue);
    }

日志记录

onBeforeSave,onBeforeDelete 方法会在执行remove和save之前触发,我们可以分别在这两个方法中记录删除条件和最终保存的对象,对于update方法,我并没有找到任何的方法。

在Mybatis中可以记录执行的SQL,在MongoTemplate中,我们也可以通过该监听器来实现。但是需要注意的是,MongoTemplate中提供的触发方法只有7个,如果执行的是aggregate,bulk等操作,无法通过监听器来记录最终执行的操作语句。

移除_class

默认情况下,在将Java对象保存至MongoDB时,MongoTemplate会在Java对象转换为Document时,会增加一个额外的_class 字段用于保存该Java对象的全限定名。

在执行查询操作时,MongoTemplate也会在查询条件上增加{_class: {$in: [java全限定名,以及子类的全限定名]}}。需要注意的是,额外的增加查询条件和原始的条件是and 操作,正常情况下是没有任何问题的,但是如果我们在插入时,使用Map作为插入的对象,手动指定CollectionName,那么MongoTemplate不会在Document上增加_class 字段(MongoTemplate对Map不做任何的处理,Document本身就是Map的子类)。

在这种情况下,我们执行查询条件时(根据条件修改,删除,查询),可能会出现查询不到的情况,根本原因便是使用Map插入的这个Document上并没有_class 字段。

解决方法有两个:1. 移除_class,2. 对于使用Map插入时,手动设置Map对象中_class 字段的值,这两种方式各有优点。

我更倾向于移除_class。如果Java对象的全限定名称比较长,并且Collection中数据比较多时,每次保存时都设置_class ,势必会导致不必要的存储空间浪费,而且_class 的作用只是通知Spring,MongoDB中保存的这条Document需要被反序列化为哪个Java类。

正常情况下,我们并不会在同一个Collection中存储多个不同的Java类型,所以在每个Document中存储_class 是完全没有必要的。

从Document中移除_class后,反序列化还正常么?

确定Document应该反序列化为哪个Java对象的工作是在org.springframework.data.convert.DefaultTypeMapper#readType(S, org.springframework.data.util.TypeInformation<T>) 方法中进行的,默认的行为是从查询到的Document中获取_class 字段的值,然后和find(Query query, Class<T> entityClass) 中的entityClass进行比较,最终决定是使用Document中的_class 还是entityClass。

我在上面也说了,通常情 况下,我们并不会在同一个Collection中保存多个不同的Java对象,所以可以直接使用entityClass作为反序列化类型就可以了。

java 复制代码
/**
 * 根据source和basicType(source来自于数据库数据),返回一个更具体的类型信息, 默认行为为,从source中获取_class,并且根据全限定名从缓存中获取,
 * 因为类型都是直接从mongoTemplate指定的,所以从{@link TypeInformation#getType()}中获取的Class便是最具体的类型
 *
 * @param source    must not be {@literal null}.
 * @param basicType must not be {@literal null}.
 * @return 类型信息
 */
@Override
public <T> TypeInformation<? extends T> readType(Bson source, TypeInformation<T> basicType) {
    Class<T> entityClass = basicType.getType();
    // 如果entityClass为空, 将执行逻辑交给父类去处理,由或者可以从本地缓存中根据CollectionName获取Collection对应的Java类的
    if (entityClass == null) {
        return super.readType(source, basicType);
    }
    ClassTypeInformation<?> targetType = ClassTypeInformation.from(entityClass);
    return basicType.specialize(targetType);
}

写操作

默认情况下,会在org.springframework.data.convert.DefaultTypeMapper#writeType(org.springframework.data.util.TypeInformation<?>, S) 方法中向Document中增加_class 字段,我们需要移除_class 字段,只需要让该方法什么都不做就行

java 复制代码
/**
 * 默认行为是在写操作时,向document中增加{_class: "全限定名"}
 *
 * @param info must not be {@literal null}.
 * @param sink must not be {@literal null}.
 */
@Override
public void writeType(TypeInformation<?> info, Bson sink) {}

查询

默认情况下,会在writeTypeRestrictions(Document result, @Nullable Set<Class<?>> restrictedTypes) 方法中向查询条件中添加{_class: {$in:[]}},这会导致在没有_class 字段时,查询出错,解决方案也是重写writeTypeRestrictions 方法,让它什么都不做

java 复制代码
/**
 * 默认行文是在查询的时候,向语句中写入{_class: {$in: []}}
 *
 * @param result          must not be {@literal null}
 * @param restrictedTypes must not be {@literal null}
 */
@Override
public void writeTypeRestrictions(Document result, Set<Class<?>> restrictedTypes) {}

主键

在MongoDB中,主键字段名是固定的_id,默认情况下,如果在插入时,没有指定主键字段的值,那么MongoDB会自动生成一个ObjectId类型的值作为_id的值。

使用MongoTemplate执行insert操作时,也可以像Mybatis那样,如果对象中主键值缺失,那么保存成功后,MongoTemplate会将MongoDB自动生成的_id 值赋值给Java对象中@Id 注解修饰的字段值。

java 复制代码
User user = new User();
user.setUsername("xcye");
user.setPassword("xcye");
User insert = mongoTemplate.insert(user);
// insert.id = xxxx

如果需要MongoTemplate自动设置id字段的值,必须保证id字段的类型是ObjectId, String,BigInteger ,否则在插入时,会抛出异常,具体判断方法请看org.springframework.data.mongodb.core.EntityOperations.MappedEntity#assertUpdateableIdIfNotSet

自定义_id转换器

这是一个坑,假如User这个Collection中,使用userId作为_id 字段的值,这是一个字符串。当我们通过userId查询,修改,删除,可能会出现查询不到对应记录的情况,但是我们传入的userId确是真实存在的,而且这种情况只存在于部分userId中。

出现这种情况的原因是因为,MongoTemplate在执行时,会对传入的_id字段进行推断,其会判断传入的这个_id 是否是ObjectId类型,如果能转成ObjectId的话,那么MongoTemplate会使用ObjectId对象作为_id 的值,但是因为MongoDB中_id 字段的类型是普通的字符串,并非是ObjectId,所以就会出现查询不到的情况。

_id(对应于Java对象中的userId): String username: String password: String
66aeeb73142fcf1d5591c29c xcye 123456

我们传入的查询条件:

java 复制代码
db.User.find({_id: "66aeeb73142fcf1d5591c29c"})

MongoTemplate执行时,推断出66aeeb73142fcf1d5591c29c能够转为ObjectId类型,于是最终的查询条件变为:

java 复制代码
db.User.find({_id: new ObjectId("66aeeb73142fcf1d5591c29c")})

这个过程是在MongoConverter#convertId 方法中完成的

java 复制代码
default Object convertId(@Nullable Object id, Class<?> targetType) {

		if (id == null || ClassUtils.isAssignableValue(targetType, id)) {
			return id;
		}
       // Spring推断出66aeeb73142fcf1d5591c29c能够转为ObjectId,于是targetType为ObjectId
		if (ClassUtils.isAssignable(ObjectId.class, targetType)) {
			if (id instanceof String) {
               // 字符串被转为了ObjectId
				if (ObjectId.isValid(id.toString())) {
					return new ObjectId(id.toString());
				}

				// avoid ConversionException as convertToMongoType will return String anyways.
				return id;
			}
		}

		try {
			return getConversionService().canConvert(id.getClass(), targetType)
					? getConversionService().convert(id, targetType)
					: convertToMongoType(id, (TypeInformation<?>) null);
		} catch (ConversionException o_O) {
			return convertToMongoType(id,(TypeInformation<?>)  null);
		}
	}

所以为了避免普通的字符串被转为ObjectId,我们需要重写convertId方法。只需要创建一个类继承自MappingMongoConverter类,并且重写其中的convertId就可以了。

java 复制代码
@AutoConfiguration(after = MongoAutoConfiguration.class, before = MongoDataAutoConfiguration.class)
@ConditionalOnSingleCandidate(MongoClient.class)
public class MongoAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(MongoConverter.class)
    MappingMongoConverter mappingMongoConverter(MongoDatabaseFactory factory, MongoMappingContext context,
                                                MongoCustomConversions conversions) {
        DbRefResolver dbRefResolver = new DefaultDbRefResolver(factory);
        //
        MappingMongoConverter mappingConverter = new MappingMongoConverter(dbRefResolver, context) {
            @Override
            public Object convertId(Object id, Class<?> targetType) {
                if (id == null) {
                    return null;
                }

                if (id instanceof String) {
                    return id;
                }
                
                // 其他的转换
            }
        };
        mappingConverter.setCustomConversions(conversions);
        return mappingConverter;
    }
}

数据库自动切换

使用MongoTemplate操作时,我们可以动态的切换MongoDB数据库,这个功能在分库的场景下非常好用,动态切换MongoDB数据库是通过MongoDatabaseFactorySupport 来完成的。

MongoTemplate在每次执行时,都会调用org.springframework.data.mongodb.core.MongoTemplate#doGetDatabase 获取操作的数据库,我们只需要创建自己的MongoDatabaseFactory,在getMongoDatabase方法中返回操作的数据库就行,可以参照SimpleMongoClientDatabaseFactory

java 复制代码
@AutoConfiguration(after = MongoAutoConfiguration.class, before = MongoDataAutoConfiguration.class)
@ConditionalOnSingleCandidate(MongoClient.class)
public class MongoAutoConfiguration {

    @Bean
    MongoDatabaseFactorySupport<?> mongoDatabaseFactory(MongoClient mongoClient, MongoProperties properties) {
        return new CustomMongoDatabaseFactory(mongoClient, properties.getMongoClientDatabase());
    }
}

因为MongoDB是NoSQL数据库,在操作时,并不需要像SQL数据库那样,必须要数据库和数据库表存在才可以。MongoDB执行时,如果数据库或Collection不存在,那么其会自动创建。

相关推荐
摇滚侠2 小时前
Spring Boot 3零基础教程,IOC容器中组件的注册,笔记08
spring boot·笔记·后端
程序员小凯5 小时前
Spring Boot测试框架详解
java·spring boot·后端
你的人类朋友5 小时前
什么是断言?
前端·后端·安全
程序员小凯6 小时前
Spring Boot缓存机制详解
spring boot·后端·缓存
i学长的猫7 小时前
Ruby on Rails 从0 开始入门到进阶到高级 - 10分钟速通版
后端·ruby on rails·ruby
用户21411832636027 小时前
别再为 Claude 付费!Codex + 免费模型 + cc-switch,多场景 AI 编程全搞定
后端
茯苓gao7 小时前
Django网站开发记录(一)配置Mniconda,Python虚拟环境,配置Django
后端·python·django
Cherry Zack7 小时前
Django视图进阶:快捷函数、装饰器与请求响应
后端·python·django
爱读源码的大都督8 小时前
为什么有了HTTP,还需要gPRC?
java·后端·架构
码事漫谈8 小时前
致软件新手的第一个项目指南:阶段、文档与破局之道
后端