MongoDB保姆级指南(下):无缝集成SpringData框架,一篇最全面的Java接入指南!

本文涉及的产品
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 MongoDB,独享型 2核8GB
推荐场景:
构建全方位客户视图
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
简介: 前面的两篇文章已经将MongoDB大多数知识进行了阐述,不过其中的所有内容,都基于原生的MongoDB语法在操作。可是,在实际的日常开发过程中,我们并不会直接去接触MongoDB,毕竟MongoDB只能算作是系统内的一个组件,无法仅依靠它来搭建出一整套系统。

引言

前面的两篇文章已经将MongoDB大多数知识进行了阐述,不过其中的所有内容,都基于原生的MongoDB语法在操作。可是,在实际的日常开发过程中,我们并不会直接去接触MongoDB,毕竟MongoDB只能算作是系统内的一个组件,无法仅依靠它来搭建出一整套系统。

我们都是高级语言的开发者,就如同MySQL一样,在不同语言中,都会有各自的驱动、ORM框架,MongoDB亦是如此,而在Java中如何使用MongoDB呢?总共有两种方案:

  • MongoDB-Driver:官方提供的数据库驱动,可以基于此执行各种MongoDB支持的操作;
  • Spring-Data-MongoDBSpring对原生驱动的封装,提供了更简易的API

通常来说,我们一般不会使用第一种方式操作MongoDB,类比MySQL,第一种方案就相当于原生的JDBC,而第二种方案就相当于MyBatis/Plus、JPA、Hibernate这种持久层框架。两者相比较,显然后者对开发者更加友好,也能在极大程度上提升开发效率,从而满足Boss的“快速开发”理念。

好了,本文会以Spring-Data-MongoDB作为客户端框架,一点点阐述在Java中如何使用MongoDB提供的众多命令,那么,下面开始吧~

其实当你掌握了Spring-Data-MongoDB的用法,也就掌握了SpringData系列的框架用法,比如Spring-Data-JPA,用起来和本文讲的Spring-Data-MongoDB没有太大区别,因为SpringData体系下的大多数框架,核心设计是共通的,如果你之前对SpringData不熟悉,本文也能助你掌握这方面的技术栈~

一、SpringData-MongoDB入门

如果使用过SpringData-JPA的小伙伴,在使用SpringData-MongoDB会有种异常的熟悉感,因为它们都出自于Spring组织,所以秉持着相同的框架风格,为什么这么说呢?一点点往下看就明白了。

这里先基于SpringBoot快速搭建出一个Java工程,接着在pom.xml中引入下述依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

这里使用的是spring-boot-starter,所以无需指定版本号,默认会跟SpirngBoot版本保持一致,我这里SpringBoot版本为2.7.15,接着可以配置一下application.yml文件,如下:

spring:
  data:
    mongodb:
      uri: mongodb://zhuzi:123456@192.168.229.136:27017/zhuzi
      # 也可以分开写
#      host: 192.168.229.136
#      port: 27017
#      database: zhuzi
#      username: zhuzi
#      password: 123456

这段配置特别容易看懂,所以不做过多解释,配置完成后,为了方便后续操作,这里再引入一下Lombok的依赖:

<dependency>
    <groupId>com.alibaba.fastjson2</groupId>
    <artifactId>fastjson2</artifactId>
    <version>2.0.27</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.12</version>
</dependency>

好了,依赖、配置都弄好后,接着来编写与MongoDB集合相映射的实体类,如下:

@Data
@Document(collection = "xiong_mao")
public class Panda implements Serializable {
   
   
    private static final long serialVersionUID = 1L;
    @Id
    private Integer id;
    private String name;
    private Integer age;
    private String color;
    private Food food;
}

@Data
public class Food implements Serializable {
   
   
    private static final long serialVersionUID = 1L;
    private String name;
    private String grade;
}

这里没啥太值得注意的,和MyBatisPlus也很相似,由于目前实体类名和集合名不一样,因此使用@Document显示指定一下集合名词,其次用@Id注解显示指定出主键。接着来看food属性,因为这是一个嵌入文档,所以咱们又定义了另外一个类Food,并将其作为了Panda类的一个属性,SpringData在映射时,会拿着food属性名+Food类里的属性名拼接,形成food.name这样的格式,正好和MongoDB嵌入文档的使用方式相同。

实体类搞好后,接着来写经典的MVC三层,目前仅是为了测通,为此,只编写持久层和业务层,这里先写前者:

@Repository
public interface PandaRepository extends MongoRepository<Panda, Integer> {
   
   }

如你所见,持久层仅仅定义了一个接口,而后继承了SpringData提供的MongoRepository接口,并传递了两个泛型,前者代表与当前Repository绑定的实体类(集合),后者表示集合内的主键类型。除此之外,咱们什么都不用写,因为SpringData会通过动态代理的方式,帮我们生成基础的CRUD方法。

接着来编写Service层,遵循传统的项目编码风格,先定义接口,再撰写实现类,接口定义如下:

public interface PandaService {
   
   
    void save(Panda panda);
    void deleteById(Integer id);
    void update(Panda panda);
    Panda findById(Integer id);
    List<Panda> findAll();
}

其中定义了五个基本的增删改查方法,注释也没写,毕竟一眼就能看懂的代码,接着看看实现类:

@Service
public class PandaServiceImpl implements PandaService {
   
   
    @Autowired
    private PandaRepository pandaRepository;

    @Override
    public void save(Panda panda) {
   
   
        pandaRepository.save(panda);
    }

    @Override
    public void deleteById(Integer id) {
   
   
        pandaRepository.deleteById(id);
    }

    @Override
    public void update(Panda panda) {
   
   
        pandaRepository.save(panda);
    }

    @Override
    public Panda findById(Integer id) {
   
   
        return pandaRepository.findById(id).get();
    }

    @Override
    public List<Panda> findAll() {
   
   
        return pandaRepository.findAll();
    }
}

又是一眼能看懂的代码,这里将前面定义的PandaRepository注入了进来,而后实现了PandaService接口的每个方法,每个方法中没有包含任何业务逻辑,只是单纯调了pandaRepository的默认方法,不过这里注意:update修改方法,调用的也是pandaRepository.save()方法实现,这是为什么呢?大家可以点进save()方法看看:

001.png

此时不难发现,这些方法最终会调用到CrudRepository接口提供的基本方法,而这个CRUD接口中,并没有提供update()系列的方法,而save()插入相同_id的数据时,会直接覆盖上一次的数据,为此,我们可以通过该方法间接实现修改功能,只不过每次修改之前,需要先find一次将原数据查询出来。

好了,剩下的就是编写测试用例了,这一步更简单,如下:

@SpringBootTest
public class PandaServiceTest {
   
   
    @Autowired
    private PandaService pandaService;

    @Test
    void testSave() {
   
   
        Panda panda = new Panda();
        panda.setId(111);
        panda.setName("黑黑");
        panda.setColor("黑色");
        panda.setAge(8);
        Food food = new Food();
        food.setName("黑晶竹");
        food.setGrade("A");
        panda.setFood(food);
        pandaService.save(panda);
    }

    @Test
    void testDelete() {
   
   
        pandaService.deleteById(111);
    }

    @Test
    void testFindById() {
   
   
        Panda panda = pandaService.findById(111);
        System.out.println(panda);
    }

    @Test
    void TestUpdate() {
   
   
        Panda panda = pandaService.findById(111);
        panda.setAge(9);
        pandaService.update(panda);
    }

    @Test
    void testFindAll(){
   
   
        List<Panda> pandas = pandaService.findAll();
        pandas.forEach(System.out::println);
    }
}

该测试类中,将咱们定义的PandaService注入了进来,而后为每一个service方法写了用例,具体的执行结果就不贴了,诸位可以自己执行一下看看结果,其实和操作MySQL没有太大差异。

二、SpringData-MongoDB进阶

OK,在前面简单过了一下SpringData-MongoDB基础的增删改查操作,但实际开发过程中,只掌握这些远远不够,面对复杂多变的业务场景,咱们需要灵活使用不同API来满足需求,为此,接下来会一点点继续拆解SpringData-MongoDB的其他API

2.1、实体类注解

前面在定义实体类时,简单介绍了@Id、@Document两个注解,这里先全面介绍一些常用注解。

  • @Document:作用于类上面,被该注解修饰的类,会和MongoDB中的集合相映射,如果类名和集合名不一致,可以通过collection参数来指定。
  • @Id:标识一个字段为主键,可以加在任意字段上,但如果该字段不为_id,每次插入需要自己生成全局唯一的主键;如果不设置@Id主键,MongoDB会默认插入一个_id值来作为主键。
  • @Transient:被该注解修饰的属性,在CRUD操作发生时,SpringData会自动将其忽略,不会被传递给MongoDB
  • @Field:作用于普通属性上,如果Java属性名和MongoDB字段名不一致,可以通过该注解来做别名映射。
  • @DBRef:一般用来修饰“嵌套文档”字段,主要用于关联另一个文档。
  • @Indexed:可作用于任意属性上,被该注解修饰的属性,如果MongoDB中还未创建索引,在第一次插入时,SpringData会默认为其创建一个普通索引。
  • @CompoundIndex:作用于类上,表示创建复合索引,可以通过name参数指定索引名,def参数指定组成索引的字段及排序方式。
  • @GeoSpatialIndexed、@TextIndexed:和上面的@Indexed注解作用相同,前者代表空间索引,后者代表全文索引。

好了,上面这些便是SpringData-MongoDB提供用于修饰实体类的注解,其实除此之外,还有些由SpingData提供的通用注解,但这里不再例举,毕竟上述这些已经够用了。

2.2、自定义方法

经过前面的案例,会发现SpringData-MongoDB提供了默认的CRUD方法,那如果现在有个需求,没有对应的默认方法怎么办?这时可以自定义方法,但SpringData是以方法命名来决定方法怎么执行的,规范如下:

  • findBy<fieldName>:根据指定的单个条件进行等值查询;
  • findBy<fieldName>And<fieldName>And<...>:根据指定的多条件进行and查询;
  • findBy<fieldName>Or<fieldName>Or<...>:根据指定的多条件进行or查询;
  • findBy<fieldName>Equals:根据指定的单个条件进行等值查询;
  • findBy<fieldName>In:对指定的单个字段进行in查询,入参为一个列表;
  • findBy<fieldName>Like:对指定的单个字段进行like模糊查询;
  • findBy<fieldName>NotNull:查询指定字段不为空的数据;
  • findBy<fieldName>GreaterThan:对指定的单个字段进行>范围查询;
  • findBy<fieldName>GreaterThanEqual:对指定的单个字段进行>=范围查询;
  • findBy<fieldName>LessThan:对指定的单个字段进行<范围查询;
  • findBy<fieldName>LessThanEqual:对指定的单个字段进行<=范围查询;
  • Page<...> findBy<...>:根据指定的条件进行分页查询;
  • countBy<fieldName>:根据指定的条件字段进行计数统计;
  • findTop<n>By<fieldName>:根据指定字段做等值查询,并返回前n条数据;
  • findBy<fieldName>Between:根据指定字段进行between范围查询;
  • findDistinctBy<fieldName>:根据指定的单个条件进行去重查询;
  • findFirstBy<fieldName>:根据指定的单个条件进行等值查询(只返回满足条件的第一个数据);
  • findBy<fieldName1>OrderBy<fieldName2>:根据第一个字段做等值查询,并根据第二个字段做排序;
  • ……

大家看下来不难发现,SpringData-MongoDB其实提供了一组命名约束,结合自定义的实体类字段,可以让SpringData帮我们生成各类执行语句,这里总体归类一下,在Repository中自定义方法,不同单词开头的含义如下:

  • get、find、read、query、stream开头,代表是查询数据的方法;
  • count开头,代表是计数统计的方法;
  • delete、remove开头,代表是删除数据的方法;
  • exists开头,代表是判断是否存在的方法;
  • search开头,代表是全文搜索的方法;
  • update开头,代表是修改数据的方法;

上面这些便是SpringData-MongoDB所有支持的方法类型,这些方法后面可以跟关键字,以find开头的方法为例:

  • By:表示当前方法生成的查询语句,会根据By后面的逻辑来组成;
  • FirstBy:表示当前方法生成的语句,只会返回符合条件的第一条数据;
  • DistinctBy:表示当前方法生成的语句,会对符合条件的数据去重;
  • TopBy:表示当前方法生成的语句,只会返回符合条件的前N条数据;
  • <实体类名称>By:表示当前方法生成的语句,只会返回一条数据;
  • <实体类名称>sBy:表示当前方法生成的语句,会返回多条数据;
  • AllBy:表示当前方法生成的语句,会返回多条或所有数据;
  • DistinctFirstBy:表示当前方法生成的语句,只会返回去重后的第一条数据;
  • DistinctTopBy:表示当前方法生成的语句,只会返回去重后的前N条数据;

OK,在这些关键字之后,都是跟具体的字段名(实体类的属性名),字段名称后面可以接的关键字如下(同样以find为例):

  • Or:表示当前查询方法有多个条件,多个条件之间为“或者”关系;
  • And:表示当前查询方法有多个条件,多个条件之间为“并且”关系;
  • OrderBy:表示当前查询会涉及到排序,后面需要跟一个排序字段;
  • Between:表示当前方法为between范围查询;
  • GreaterThan:表示当前方法为>查询;
  • GreaterThanEqual:表示当前方法为>=查询;
  • LessThan:表示当前方法为<查询;
  • LessThanEqual:表示当前方法为<=查询;
  • After:和GreaterThan差不多,相当于查询指定数值之后的数据;
  • Before:和LessThan差不多,查询指定条件之前的数据;
  • Containing:查询某字段中包含指定字符的数据;
  • Empty:表示当前方法会查询指定字段为空的数据,与之含义类似的还有Null、Exists
  • Equals:表示当前方法会根据指定字段做等值查询;
  • Is:和Equals差不多;
  • In:表示当前方法为in多值匹配查询;
  • Like:表示当前方法为like模糊查询;
  • Not:可以和上述大多数关键字组合,带有Not的则含义相反,如NotEmpty表示不为空;
  • ……

上面列出了大多数常用的连接关键字,看着是不是尤其的多?但其实不需要你死记,因为当你在IDEA工具编写方法时,它也会自动提示你!这里随便来定义几个方法,帮助诸位加深理解:

@Repository
public interface PandaRepository extends MongoRepository<Panda, Integer> {
   
   
    // 查询指定年龄的熊猫数量
    Integer countByAge(Integer age);

    // 对熊猫姓名进行全模糊查询
    List<Panda> findByNameLike(String keyword);

    // 查询同时满足年龄、姓名两个条件的熊猫
    Panda findPandaByAgeAndName(int age, String name);

    // 查询满足颜色、年龄其中任一条件的所有熊猫
    List<Panda> findByColorOrAge(String color, int age);

    // 查询第一个带有颜色的熊猫
    Panda findFirstByColorNotNull();

    // 查询年龄大于等于指定岁数的熊猫
    List<Panda> findByAgeGreaterThanEqual(int age);

    // 对熊猫ID进行多值查询
    List<Panda> findByIdIn(List<Integer> ids);

    // 查询指定颜色的熊猫,并按照年龄降序返回
    List<Panda> findByColorOrderByAgeDesc(String color);

    // 查询年龄小于指定岁数的前三条数据
    List<Panda> findTop3ByAgeLessThan(int age);
}

上面定义的方法就不挨个测试了,其实效果就如同注释中所说的一样,感兴趣的可以自行测试看看。

这里说个题外话,编程里有句话叫做:好的代码不用写注释,这点在SpringData中体现的淋漓尽致,当你熟悉它这套命名约束后,再去看别人定义的Repository方法时,就算他人不写注释,你也完全可以看懂,Why?因为你们遵循着完全相同的编码规范、命名约束,他不可能写其他风格的代码,毕竟命名不按规范来,定义的方法压根无法执行~

2.3、分页查询

前面了解了SpringDataMongoDB中的自定义方法,接着来看看经典的“分页查询”,在这里该怎么玩呢?

首先需要定义一个Repository方法,如下:

// 对年龄不为空的熊猫数据进行分页
Page<Panda> findByAgeNotNull(Pageable pageable);

命名约束完全相同,区别就在于返回类型,以及入参类型不同,这里的返回类型声明成了Page<Panda>,而入参列表中需要新增一个Pageable,接着继续封装一下Service层:

// 在PandaService接口中定义分页方法
Page<Panda> pageByAgeNotNull(int pageNumber, int pageSize);

// 在PandaServiceImpl实现类中实现具体逻辑
@Override
public Page<Panda> pageByAgeNotNull(int pageNumber, int pageSize) {
   
   
    // 使用SpringData中内置的分页请求对象,直接将分页参数转换为请求对象
    PageRequest pageReq = PageRequest.of(pageNumber - 1, pageSize);
    // 调用Repository的分页方法
    return pandaRepository.findByAgeNotNull(pageReq);
}

写好Service层后,为了测试分页功能是否正常,这里再编写一个单元测试:

@Test
void testPage(){
   
   
    Page<Panda> pandas = pandaService.pageByAgeNotNull(1, 3);
    int totalPages = pandas.getTotalPages();
    long totalElements = pandas.getTotalElements();
    System.out.println("总页数:" + totalPages);
    System.out.println("总行数:" + totalElements);
    System.out.println("第一页的数据为:");
    List<Panda> data = pandas.getContent();
    // 也可以直接遍历pandas这个Page对象
    data.forEach(System.out::println);
}

这个用例很简单,无非就是调用了一下service方法,而后传递了页码、条数,接着从Page对象中,拿到了总页数、总行数,以及具体的分页数据,输出结果如下:

总页数:6
总行数:16
第一页的数据为:
Panda(id=1, name=肥肥, age=3, color=黑白色, food=null)
Panda(id=4, name=黑熊, age=3, color=黑白色, food=Food(name=黄金竹, grade=S))
Panda(id=5, name=白熊, age=4, color=null, food=Food(name=翠绿竹, grade=B))

效果很明显,总共有16条数据,每页三条总共6页,这里获取了第一页的数据,拿到的分页结果也符合age字段不为空的特性,当然,大家也可以自行改改页码测试,这里不再重复贴结果,毕竟分页使用起来尤为简单。

2.4、自定义MongoDB语句

前面掌握了遵循SpringData的命名规范,通过自定义Repository方法,完成了对MongoDB数据的增删改查操作,但这种方式有个缺陷,就是必须遵守规范去给方法命名,使用时难免会有种约束感,能不能自由一点呢?当然可以。

以前操作传统关系型数据库时,无论是在任何ORM框架中,都支持通过注解来编写简单SQL,例如MyBatis

@Select("select * from zhu_zi where id = #{id}")
ZhuZi selectById(@Param("id") Integer id);

那在SpringDataMongoDB中,支不支持这种注解形式定义语句呢?答案是当然可以,来看个例子:

// Repository方法定义
@Query("{'name': ?0}")
List<Panda> queryXxx(String name);

// Service接口定义
List<Panda> queryXxx(String name);

// ServiceImpl接口实现
@Override
public List<Panda> queryXxx(String name) {
   
   
    return pandaRepository.queryXxx(name);
}

这个代码特别简单,重点看repository里定义的接口方法:queryXxx(),在方法上面有一个@Query注解,这用于自定义查询语句,其中声明根据name字段进行查询,?0表示方法参数的索引(占位符),此处的0表示第一个参数name,下面看看测试结果:

// Junit测试用例
@Test
void testQueryXxx() {
   
   
    List<Panda> pandas = pandaService.queryXxx("肥肥");
    pandas.forEach(System.out::println);
}

/* 执行结果:
    Panda(id=1, name=肥肥, age=3, color=粉色, food=null)
*/

这里并没有遵循SpringData的命名规范,但仍然根据name字段查询到了数据,效果同预期中的一样,除此之外,还有另外几个注解,分别对应其他操作:

  • @Update:用于自定义更新语句的注解;
  • @DeleteQuery:用于自定义删除语句的注解;
  • @CountQuery:用于自定义统计语句的注解;
  • @ExistsQuery:用于自定义查询语句,但执行后只返回是否存在满足条件的数据,并不返回具体的文档;
  • @Aggregation:用于自定义聚合管道语句的注解;
  • ……

除开上述列出的外,其实还有一些注解,具体大家可以去看看org.springframework.data.mongodb.repository这个包,关于这些注解的用法就不再过多说明,毕竟用注解写原生语句的机会并不多,实际开发中,要么根据命名规范自定义方法,要么使用MongoTemplate写复杂操作(后续会细说)。

2.5、SpingDataMongoDB事务机制

在之前的篇章中提到过,MongoDB本身支持事务,那SpingDataMongoDB中该如何使用事务呢?如下:

// Service层定义接口
void mongoTransaction();

// ServiceImpl具体实现
@Override
public void mongoTransaction() {
   
   
    // 1.先通过mongoClient开启一个session会话
    ClientSession session = mongoClient.startSession();
    try{
   
   
        // 2.通过session开启事务
        session.startTransaction();

        // 3.创建一个实体对象
        Panda panda = new Panda();
        panda.setId(222);
        panda.setName("白白");
        panda.setColor("白色");
        panda.setAge(1);

        // 4.通过mongoClient获取集合对象
        MongoCollection<Document> collection = mongoClient
                .getDatabase("zhuzi")
                .getCollection("xiong_mao");
        // 5.通过集合对象提供的insert方法插入数据
        collection.insertOne(session, Document.parse(JSONObject.toJSONString(panda)));

        // 6.模拟执行异常
        int n = 100 / 0;

        // 7.如果执行到这里,说明执行没报错,提交事务
        session.commitTransaction();
    } catch (Exception e) {
   
   
        // 8.如果进入了catch,说明出现异常,回滚事务
        session.abortTransaction();
        e.printStackTrace();
    }
    // 9.关闭前面开启的session会话
    session.close();
}

OK,对于每一步代码的含义,这里已经在上面写出了注释,为此不再重复说明,整个流程很简单,上面是一个向MongoDB插入单个文档的方法,如果执行成功,提交事务;如果执行出现异常,回滚事务,但要注意几点:

  • ①事务依赖于ClientSession开启,所以需要先打开ClientSession会话;
  • ②想要让当前写操作的事务生效,必须调用MongoCollection的写方法;
  • ③前两个对象的获取,都依赖于MongoClient对象,所以要记得注入该对象,如下:
@Autowired
private MongoClient mongoClient;

接着来写个测试用例,看看事务到底会不会生效呢?如下:

@Test
void testMongoTransaction() {
   
   
    pandaService.mongoTransaction();
}

按理来说,由于咱们手动加了一行100/0,因此肯定会报错而后回滚,但是来看看实际执行结果:

Caused by: com.mongodb.MongoCommandException: 
    Command failed with error 20 (IllegalOperation): 
        'Transaction numbers are only allowed on a replica set member 
        or mongos' on server 192.168.229.136:27017. The full response is 
        {
   
   "ok": 0.0, "errmsg": "Transaction numbers are only allowed on a 
        replica set member or mongos", "code": 20, "codeName": "IllegalOperation"}

这个报错很明显,并不是取模零的异常,而是告诉咱们,事务只能在replicaSet副本集,或者mongos分片集群下生效,在单机的MongoDB上,事务无法正常开启,为此,在使用单机MongoDB时,事务并不能正常生效(如果你搭建了集群环境,上述代码是能正常执行、然后回滚数据的)。

三、MongoTemplate

前面的内容中,简单过了一下SpringData自定义方法、如何定义分页查询,以及如何通过注解自定义原生语句。不过更多时候,咱们都是在遵循着SpringData的命名规范编写,语句的生成、执行、数据聚合,全由SpringData自动完成。但,如果有一个特殊的需求,无法通过前面的方式查询到数据怎么办?这里就得用到MongoTemplate,可以用它来实现更加灵活的增删改查操作。

3.1、MongoTemplate方法分类

MongoTemplate中提供了不同的方法,用于执行不同类型的操作,首先来看看插入方法:

// 插入一个文档(自动根据对象类型解析目标集合)
T insert(T objectToSave);

// 向指定的集合插入一个文档
T insert(T objectToSave, String collectionName);

// 批量插入多个文档
Collection<T> insertAll(Collection<? extends T> objectsToSave);

// 向指定的集合里批量插入多个文档
Collection<T> insert(Collection<? extends T> batchToSave, String collectionName);
Collection<T> insert(Collection<? extends T> batchToSave, Class<?> entityClass);

// 批量插入多个文档,并设置写关注级别
Collection<T> doInsertBatch(String collectionName, Collection<? extends T> batchToSave,
                            MongoWriter<T> writer);

// 这两个方法等价于前面的insert方法
T save(T objectToSave);
T save(T objectToSave, String collectionName);

观察下来会发现,这些方法对应着MongoDB自身提供的API,即insertOne、insertMany,如果入参T已经与一个集合映射,则无需额外指定类型、集合名,MongoTemplate会自动解析。但如果你想将一个字段完全不同的文档插入到某个集合,则可以显式指定集合名称。

接着来看看删除方法:

// 删除一个文档(根据入参类型自动解析目标集合)
DeleteResult remove(Object object);

// 从指定集合中删除一个文档
DeleteResult remove(Object object, String collectionName);

// 根据条件从指定集合中删除一个文档
DeleteResult remove(Query query, Class<?> entityClass);
DeleteResult remove(Query query, Class<?> entityClass, String collectionName);
DeleteResult remove(Query query, String collectionName);

// 根据指定条件删除文档,并返回删除前的文档(默认只对第一个满足条件的文档生效)
<T> T findAndRemove(Query query, Class<T> entityClass);

删除方法命名中都带remove,可以直接传入一个对象进行删除,也可以根据条件进行删除,没啥好讲的,接着看看修改方法:

// 更新指定的文档
ExecutableUpdate<T> update(Class<T> domainType);

// 更新满足条件的第一个文档(如果未找到满足条件的文档,则将当前数据插入到集合)
UpdateResult upsert(Query query, UpdateDefinition update, Class<?> entityClass);

// 更新满足条件的第一个文档
UpdateResult updateFirst(Query query, UpdateDefinition update, String collectionName);

// 更新满足条件的所有文档
UpdateResult updateMulti(Query query, UpdateDefinition update, Class<?> entityClass);

// 根据指定条件删除文档,并返回删除前的文档(默认只对第一个满足条件的文档生效)
T findAndModify(Query query, UpdateDefinition update, Class<T> entityClass);

// 根据指定条件替换文档,并返回删除前的文档(默认只对第一个满足条件的文档生效)
<S, T> T findAndReplace(Query query, S replacement, FindAndReplaceOptions options, 
        Class<S> entityType, String collectionName, Class<T> resultType);

修改方法有点多,所以这里省略了一些同名不同参的方法,其中只有updateMulti方法会更新所有满足条件的文档,其余方法都只会更新“满足条件”的第一个文档。为此,在使用时要额外注意:当你的操作涉及到多个文档修改时,请记住调用updateMulti()方法,而不是其他修改方法

好了,关于这些方法如何使用放到后面说,再来看看查询方法,这也是MongoTemplate中最多的一类:

// 查询指定集合里是否存在满足条件的数据
boolean exists(Query query, String collectionName);

// 根据ID查询数据
T findById(Object id, Class<T> entityClass);

// 返回满足查询条件的单条数据
T findOne(Query query, Class<T> entityClass);

// 返回满足查询条件的多条数据
List<T> find(Query query, Class<T> entityClass);

// 查询指定集合中的所有文档
List<T> findAll(Class<T> entityClass, String collectionName);

// 返回满足条件的所有数据(并根据指定字段去重)
List<T> findDistinct(Query query, String field, Class<?> entityClass, Class<T> resultClass);

// 统计指定文档中,满足查询条件的文档数量
long count(Query query, String collectionName);

// 使用聚合管道查询数据
AggregationResults<O> aggregate(TypedAggregation<?> aggregation, Class<O> outputType);

上面同样省略了一些同名的重载方法,以及某些不常用的方法,诸位可以简单瞟几眼。除开上述增删改查四类方法外,MongoTemplate还提供了执行聚合管道命令的方法,如下:

// 在指定集合中,执行aggregate聚合管道操作
AggregationResults<O> aggregate(Aggregation aggregation, 
                        String collectionName, Class<O> outputType)

// 支持异步Flux流(响应式编程)的聚合管道操作
CloseableIterator<O> aggregateStream(Aggregation aggregation, 
                        String collectionName, Class<O> outputType)

// 执行给定的aggregate聚合管道操作(这种方式无需传入集合名,而是采用Java映射类的形式)
AggregationResults<O> aggregate(TypedAggregation<?> aggregation, Class<O> outputType);

这里同样省略了一些重载方法,不过这并不重要,执行聚合管道操作的方法分为两大类,一类是同步执行的aggregate方法,即命令发给MongoDB服务端后,需要阻塞等待至MongoDB返回结果为止;而另一类是支持响应式编程的aggregateStream方法,可以通过调用subscribe()方法或其他响应式编程操作来处理聚合结果(后面再细说)。

前面对MongoTemplate的常用方法有了一定认识后,下面来说说如何使用MongoTemplate

3.2、Query与Criteria对象

在上一节中,大家会看到许多入参类型为Query的方法,这到底是个啥东东?

用过MyBatisPlus框架的小伙伴一定不陌生,在MP中查询、修改、删除数据时,如果不想编写xml文件,咱们可以通过Warpper条件构造器,以Java代码的形式指定where条件,从而快捷、方便的实现条件修改、查询、删除功能。

SpringData-MongoDBQuery、Criteria对象的作用亦是如此,通过Java对象的方式,完全取代掉MongoDB原生的shell语法,比如你想实现一个条件查询,可以这样写:

// Servcie接口方法定义
List<Panda> findByColor(String color);

// ServiceImpl中的具体实现
public List<Panda> findByColor(String color) {
   
   
    Query query = new Query();
    query.addCriteria(Criteria.where("color").is(color));
    return mongoTemplate.find(query, Panda.class);
}

这样就实现了按照颜色字段来查询数据的效果,当然,上面的查询条件还可以简写为:

Query query = Query.query(Criteria.where("color").is(color));

这样指定的条件,效果和前面new的方式相同,来看看效果:

@Test
void testFindByColor(){
   
   
    List<Panda> pandas = pandaService.findByColor("黑白色");
    pandas.forEach(System.out::println);
}

/* 输出结果如下:
    Panda(id=1, name=肥肥, age=3, color=黑白色, food=null)
    Panda(id=2, name=花花, age=null, color=黑白色, food=null)
    Panda(id=4, name=黑熊, age=3, color=黑白色, food=Food(name=黄金竹, grade=S))
*/

从结果中可以看出,和我们预期中的效果相同,的确实现了按颜色查询集合文档的效果。

3.2.1、Query查询对象详解

经过上述小案例的学习后,各位应该大致对SpringData中的条件构造器有了基本认知,接着来详细说说Query对象,各方法的作用如下:

// 定义条件查询对象
Query.query(CriteriaDefinition criteriaDefinition);

// 在现有的Query对象上,新增一个条件对象
addCriteria(CriteriaDefinition criteriaDefinition);

// 在现有查询对象上,新增一个Sort排序对象
with(Sort sort);

// 在现有查询对象上,新增一个Pageable分页对象
with(Pageable pageable);

// 查询结束后,跳过指定的前skip条数据
skip(long skip);

// 查询结束后,限制返回的行数
limit(int limit);

// 查询结束后,限制返回的字段(投影查询)
Field fields();

好了,Query类中实则定义了几十个方法,但较为常用的则是上面列出的这几个,通常咱们理解上面这几个方法的作用即可,下面结合前面的案例,简单过一下这些方法:

// 一、定义Service接口方法
List<Panda> findByColorAndAge(String color, int age);

// 二、编写Impl子类实现
@Override
public List<Panda> findByColorAndAge(String color, int age) {
   
   
    // 1. 通过query()方法快速定义带条件的查询对象
    Query query = Query.query(Criteria.where("color").is(color));
    // 2. 通过addCriteria()方法,在现有的查询对象上新增一个条件
    query.addCriteria(Criteria.where("age").is(age));
    // 3. 通过with()方法添加一个排序对象(基于_id降序)
    query.with(Sort.by(Sort.Direction.DESC, "_id"));
    // 4. 通过skip()方法跳过结果集中的第一条数据
    query.skip(1);
    // 5. 通过limit()方法限制返回的行数为一条
    query.limit(1);
    // 6. 通过fields()方法指定返回的字段
    query.fields().include("_id", "name", "age");
    return mongoTemplate.find(query, Panda.class);
}

// 三、撰写单元测试用例
@Test
void testFindByColorAndAge(){
   
   
    List<Panda> pandas = pandaService.findByColorAndAge("黑白色", 3);
    pandas.forEach(System.out::println);
}

这个案例中,几乎将前面列出的方法都用上了,接着来看看结果:

/* 集合内符合查询条件的数据如下:
    Panda(id=1, name=肥肥, age=3, color=黑白色, food=null)
    Panda(id=4, name=黑熊, age=3, color=黑白色, food=Food(name=黄金竹, grade=S))
*/

/* 输出结果如下:
    Panda(id=1, name=肥肥, age=3, color=null, food=null)
*/

来根据代码推导一下过程,首先基于给定的条件,会找到id=1、4这两条数据;其次会基于id降序,结果集变为4、1这个顺序;然后回通过skip跳过第一条数据,即id=4这条数据;接着通过limit限制返回一条数据,拿到id=1这条数据;最后通过fields指定返回的字段,因为没有要求返回color、food,所以最终结果集相应字段为null

好了,这个结果和咱们预期中的完全相同,不过上面这些案例中,又涉及到了一些新对象,如Sort、Field等,这些对象也会有相应API,这里简单过一下,先来看看Sort类:

// 仅有的构造器,使用private关键字修饰
private Sort(Direction direction, List<String> properties);

// 对外提供by()方法来创建实例对象
public static Sort by(String... properties);
public static Sort by(List<Order> orders);
public static Sort by(Order... orders);
public static Sort by(Direction direction, String... properties);

// 快速创建升序、降序对象的方法
public Sort descending();
public Sort ascending();

// 默认的排序方式(升序)
public static final Direction DEFAULT_DIRECTION;
static {
   
   
    DEFAULT_DIRECTION = Sort.Direction.ASC;
}

这里咱们重点关注by()方法即可,排序主要有两个入参,一个是排序方式,另一个是排序字段,如果调用不指定排序方式的by方法,默认使用DEFAULT_DIRECTION升序方式。再者,我们可以直接指定这两个参数,也可以封装成Order对象,Order内部类如下:

public static class Order implements Serializable {
   
   
    // 排序方式
    private final Direction direction;
    // 排序字段
    private final String property;

    // 使用默认的升序方式,为传入的字段构建Order对象
    public static Order by(String property) {
   
   
        return new Order(Sort.DEFAULT_DIRECTION, property);
    }
    // 使用升序方式为传入的字段构建Order对象
    public static Order asc(String property) {
   
   
        return new Order(Sort.Direction.ASC, property, DEFAULT_NULL_HANDLING);
    }
    // 使用降序方式为传入的字段构建Order对象
    public static Order desc(String property) {
   
   
        return new Order(Sort.Direction.DESC, property, DEFAULT_NULL_HANDLING);
    }
    // 判断一个Order对象是否为升序
    public boolean isAscending() {
   
   
        return this.direction.isAscending();
    }
    // 判断一个Order对象是否为降序
    public boolean isDescending() {
   
   
        return this.direction.isDescending();
    }

    // 省略其他成员与方法……
}

Order类并不难懂,无非是对排序方式、排序字段的封装,用起来也差不多,只不过在使用时,需要先构建一个Order对象,而后再传入到Sort对象中,如下:

query.with(Sort.by(Sort.Order.desc("_id")));

好了,另一个Direction类没啥好讲的,就是一个枚举类,里面就定义了ASC、DESC两个枚举,下面来看看Filed类,该类主要用于投影查询,即调用Query.fileds()方法时产生的对象,如下:

// Query.fileds()方法的定义
public Field fields();

public class Field {
   
   
    // 指定本次查询需要返回的字段
    public Field include(String... fields);
    // 指定本次查询需要排除的字段
    public Field exclude(String... fields);
    // 用于数组型字段,offset代表切片起始位置,size表示要获取的元素数量
    public Field slice(String field, int offset, int size);
    // 返回数组字段中,指定下标的元素值
    public Field position(String field, int value);
    // 用于再次过滤数组/嵌套文档中的数据
    public Field elemMatch(String field, Criteria elemMatchCriteria);
    // 省略其他不常用方法......
}

上面列出了Filed类中的常用方法,这里主要关心前面两个方法,一个是指定需要返回的字段列表,另一个是指定需要排除的字段列表,如下:

// 指定返回name、age字段,并强制将_id字段排除
query.fields().include("name", "age").exclude("_id");

而对于后面的几个方法,都是用于数组/嵌套类型的字段,一般情况下使用较少,为此,这里不再举例说明,下面重点来看看Criteria条件构造器对象。

3.2.2、Criteria条件对象详解

MongoTemplate中提供的大部分增删改方法,其中都会有一个Query类型的入参,而Query对象无法指定过滤条件,因此需要结合Criteria对象来指定条件,大家可以把Criteria理解成MyBatisPlus框架的Warpper对象,当然,它们两者在使用上也相差不大。

在前面的案例中,咱们曾写过这样的代码:

Query query = Query.query(Criteria.where("color").is(color));
query.addCriteria(Criteria.where("age").is(age));

其实这两者都是在指定过滤条件,最终都会转变成一个Criteria对象传入到Query对象中,下面来看看其中的方法:

// 过滤条件的起始方法(类似于SQL里的where关键字)
public static Criteria where(String key);

// 用于连接多个条件(and并且关系)
public Criteria and(String key);

// 匹配所有与指定值相等的文档(=)
public Criteria is(Object value);

// 匹配所有指定字段为空的文档(is null)
public Criteria isNull();

// 匹配所有不等于指定值的文档(!=)
public Criteria ne(@Nullable Object value);

// 匹配所有小于指定值的文档(<)
public Criteria lt(Object value);

// 匹配所有小于、等于指定值的文档(<=)
public Criteria lte(Object value);

// 匹配所有大于指定值的文档(>)
public Criteria gt(Object value);

// 匹配所有大于、等于指定值的文档(>=)
public Criteria lt(Object value);

// 匹配所有与指定的多个值、其中一个相等的文档(in)
public Criteria in(Object... values);

// 匹配所有与指定的多个值、不相等的文档(not in)
public Criteria nin(Object... values);

// 匹配所有与指定的多个值、完全相等的文档(用于数组字段)
public Criteria all(Object... values);

// 匹配指定字段是否存在的所有文档(true:存在,false:不存在)
public Criteria exists(boolean value);

// 可以与上面大多数方法配合使用,含义为取反
public Criteria not();

// 匹配所有符合正则表达式的文档(可用于实现like查询)
public Criteria regex(String regex);

// 连接多个条件的方法(or或者关系)
public Criteria orOperator(Criteria... criteria);

// 连接多个条件的方法(and并且关系)
public Criteria andOperator(Criteria... criteria);

// 连接多个条件的方法(nor非或关系)
public Criteria norOperator(Criteria... criteria);

// 省略其他方法.......

上面列出了Criteria类中大多数常用方法,其他一些要么属于操作嵌套文档、地理空间的方法,要么就比较小众冷门的方法,所以不再详细罗列。其实大家观察这些方法的命令,应该也能摸清楚大致含义,无非就是原生语法,转变成了Java代码形式,前面自定义Repository方法也是相同的含义,将各种条件表达式,以“方法命名规范”的形式来编写。

毕竟这里提供的方法,和前面的命名规范有很多共通之处,因此就随便来个例子练习一下:

// 查询所有age>=3,并且food.grade=S、B的文档
Criteria criteria = Criteria.where("age")
                            .gte(3)
                            .and("food.grade")
                            .in("S", "B");
Query query = Query.query(criteria);
List<Panda> pandas = mongoTemplate.find(query, Panda.class);
pandas.forEach(System.out::println);

/* 输出结果如下:
    Panda(id=5, name=白熊, age=4, color=null, food=Food(name=翠绿竹, grade=B))
    Panda(id=4, name=黑熊, age=3, color=黑白色, food=Food(name=黄金竹, grade=S))
    Panda(id=12, name=金熊, age=4, color=null, food=Food(name=黄金竹, grade=S))
* */

这个结果和代码没啥好说的,一眼就能看懂。不过这里注意:之前咱们写的例子,几乎每一步都是分开写的,但要注意,SpringDataMongoDB中的大多数方法,都支持函数式编程的链式调用风格,即可以一直.下去,不需要每行代码之间用;分割。

3.2、Update修改对象

前面讲完了Query、Criteria两个类之后,接着来详细说说Update对象,大家对这玩意儿或许有点陌生,毕竟前面都没出现过,对不?其实该对象主要作用于“修改”方法,可仔细观察MongoTemplate提供的修改方法,貌似入参都为UpdateDefinition类型呀!这里大家可以仔细去看看Update类的定义:

public class Update implements UpdateDefinition {
   
   }

从类头能明显看出,UpdateDefinition是个接口,而Update则是具体的实现类,所以到这里就清楚了,调用MongoTemplate提供的修改方法时,其实我们需要传递的是Update对象,这里先来个案例简单使用一下:

// Service接口中定义:根据ID修改年龄的方法
UpdateResult updateColorById(Integer id, String color);

// Impl类中的具体实现
@Override
public UpdateResult updateColorById(Integer id, String color) {
   
   
    Query query = new Query(Criteria.where("id").is(id));
    Update update = new Update().set("color", color);
    return mongoTemplate.updateMulti(query, update, Panda.class);
}

// 测试用例
@Test
void testUpdateAgeById() {
   
   
    Panda oldPanda = pandaService.findById(1);
    System.out.println("修改前的数据:" + oldPanda);

    UpdateResult result = pandaService.updateColorById(1, "粉色");
    System.out.println("受影响的行数:" + result.getModifiedCount());

    Panda newPanda = pandaService.findById(1);
    System.out.println("修改前的数据:" + newPanda);
}

上面这个案例,代码也特别容易看明白,上面总共有两个对象:Query、Update,其中Query对象用于承载过滤条件,而Update对象则用来承载要修改的数据,来看看输出结果:

/*
    修改前的数据:Panda(id=1, name=肥肥, age=3, color=黑白色, food=null)
    受影响的行数:1
    修改前的数据:Panda(id=1, name=肥肥, age=3, color=粉色, food=null)
*/

从修改前后的数据对比来看,本次修改的确生效了,而且最关键的一点是:本次修改并没有触发全量替换,而是动态修改,即只变更了修改字段的值,其他字段没有变为Null!这是因为MongoTemplateupdate系列方法,底层使用了$set操作符。同时注意,Update系方法的返回值,都为UpdateResult对象,该对象主要有四个常用方法:

  • getMatchedCount():获取满足条件的文档数;
  • getModifiedCount():获取本次修改操作影响的行数;
  • getUpsertedId():如果本次修改操作为upsert操作,返回未匹配到数据、插入数据后生成的ID
  • wasAcknowledged():判断本次MongoDB服务端执行完成后,其返回值是否正确。

好了,大概认识了Update对象的用法后,接着来看看其中提供的方法:

// 将指定字段的值,修改为给定值
public static Update update(String key, @Nullable Object value);

// 将指定字段的值,修改为给定值
public Update set(String key, @Nullable Object value);

// 将指定字段的值,修改为给定值(只有在指定字段为null的情况下生效)
public Update setOnInsert(String key, @Nullable Object value);

// 删除指定字段的值
public Update unset(String key);

// 对指定字段的值进行自增(前者代表+1,后者代表+inc)
public void inc(String key);
public Update inc(String key, Number inc);

// 向指定的字段,插入当前时间
public Update currentDate(String key);

// 向指定的字段,插入当前时间戳
public Update currentTimestamp(String key);

// 向指定的数组字段末尾,追加单个元素(可以插入重复值)
public Update push(String key, @Nullable Object value);

// 向指定的数组字段末尾,追加多个元素(可以插入重复值)
public Update pushAll(String key, Object[] values);

// 从指定的数组字段中,移除指定下标的元素
public Update pop(String key, Position pos);

// 从指定的数组字段中,移除单个数组元素
public Update pull(String key, @Nullable Object value);

// 从指定的数组字段中,移除多个数组元素
public Update pullAll(String key, Object[] values);

// 向数组中添加一个值,如果该值在数组中已存在,则忽略本次插入动作
public Update addToSet(String key, @Nullable Object value);

//  向数组中添加多个值,如果插入的某个值在数组中已存在,自动忽略相应值
public AddToSetBuilder addToSet(String key);
// 上述方法必须结合AddToSetBuilder.each方法使用,如addToSet("names").each(...)
public Update each(Object... values);

// 根据条件过滤数组内的元素(结合set("filed.$[element]", "...")方法一起使用)
public Update filterArray(CriteriaDefinition criteria);

// 将指定字段的名称,修改为新的字段名
public Update rename(String oldName, String newName);

OK,上面同样未曾将所有方法列出,仅仅只写了一些常用方法,其实如果你的集合中,不涉及到数组类型的字段,那只需要关注前半部分方法即可。当然,这里只说明了每个API的作用,想练习的小伙伴可以自行去玩一玩,不会使用直接问ChatGPT即可。

3.4、Aggregation聚合管道对象

《MongoDB入门》这一篇文章中,曾经提到过一点,如果你想实现较为复杂的操作,几乎都需要通过聚合管道来完成,MongoDB提供的聚合管道十分强大,几乎可以满足任何场景下的业务操作,而在Java中如何使用聚合管道呢?答案是Aggregation对象。

先从之前的篇章,cpoy过来一个原生的聚合管道命令,如下:

/* 按年龄进行分组,并统计各组的数量(没有age字段的数据统计到一组) */
db.xiong_mao.aggregate([
    // 1:通过$group基于age分组,通过$sum实现对各组+1的操作
    {$group: {_id:"$age", count: {$sum:1}}},
    // 2:基于前面的_id(原age字段)进行排序,1代表正序
    {$sort: {_id:1}}
]);

这个案例比较简单,先基于年龄分组,再基于_id字段排序,如何转换成Java代码实现呢?如下:

@Test
void testAggregation() {
    Aggregation aggregation = Aggregation.newAggregation(
            // 基于年龄字段分组,接着统计每组梳理,并为统计字段取别名
            Aggregation.group("age").count().as("count"),
            // 基于分组后的_id字段(原age)字段排序(升序)
            Aggregation.sort(Sort.Direction.ASC, "_id")
    );
    AggregationResults<Map> results = mongoTemplate.aggregate(aggregation, "xiong_mao", Map.class);
    List<Map> maps = results.getMappedResults();
    maps.forEach(System.out::println);
}
/* 执行结果:
    {_id=null, count=3}
    {_id=1.0, count=3}
    {_id=2.0, count=4}
    {_id=3.0, count=6}
    {_id=4.0, count=2}
    {_id=9, count=1}
*/

这里为了节省篇幅,就不再写service层代码了,直接在Test方法中写逻辑,上面的代码大家参考注释即可,这里重点说说语法。认真阅读过《入门篇》的小伙伴应该还记得,MongoDB的聚合管道,可以指定多个的阶段,在每个阶段中进行不同的处理,而上述代码中亦是如此,不过该如何指定不同的阶段呢?大家看到中间的,了嘛?newAggregation()方法可以传入任意个Aggregation对象,每个对象则代表一个阶段。

同时,MongoDB原生的语法中,并没有提供$count统计操作符,想要实现计数,只能以$sum:1这种方式实现,而SpringData则封装了一个count()方法提供给咱们使用,可谓是十分贴切~

接着看看返回结果:AggregationResults对象,这个对象需要指定一个泛型,该泛型意味着返回结果的类型,我这里传入了Map,这是因为我懒得再次封装一个对象出来,如果大家以后有些聚合操作要经常执行,并且返回结果的字段已经确定,最好的做法还是封装一个entity类出来。这点先放一遍,先说说AggregationResults里的常用方法:

  • getMappedResults():返回映射的聚合结果集,即得到指定类型的集合(如案例中的List<Map>);
  • getRawResults():返回未做映射处理的原始结果集,则获取MongoDB返回的BSON对象;
  • getUniqueMappedResult():返回映射后的一个结果对象,在明确清楚聚合结果只有一条的情况使用;
  • iterator():返回用于遍历聚合结果的迭代器,可以用于逐个处理聚合结果中的数据;

对于AggregationResults对象而言,咱们只要明白这几个方法的作用即可,至于其他没列出的并不重要。

3.4.1、Aggregation案例练习

好了,上阶段对SpringData聚合管道有了简单认知后,那聚合管道的其他方法呢?怎么使用?其实把MongoDB原生的聚合管道命令掌握后,在Java里的意思也是相同的,毕竟方法名字都没改,和操作符的名称一模一样,下面来些案例玩玩:

// 案例一:基于颜色分组,并求出每组中年龄最大值,最后按年龄降序排列
@Test
void testAggregation1() {
   
   
    Aggregation aggregation = Aggregation.newAggregation(
            Aggregation.group("color").max("age").as("max_age"),
            Aggregation.sort(Sort.Direction.DESC, "max_age")
    );
    AggregationResults<Map> results = mongoTemplate.aggregate(aggregation, "xiong_mao", Map.class);
    Document bson = results.getRawResults();
    System.out.println(bson.toJson());
}
/* 执行结果:
    {
        "results": [{
            "_id": "黑色", "max_age": 9
        }, {
            "_id": null, "max_age": 4.0
        }, {
            "_id": "粉色", "max_age": 3.0
        }, {
            "_id": "黑白色", "max_age": 3.0
        }],
        "ok": 1.0
    }
*/

这个案例不难,相信大家一定能看懂,这里顺便演示了一下获取原生BSON对象,而后转换为JSON字符串输出的过程。下面再来一个稍微复杂的案例看看:

/* 案例二:过滤掉food为空,以及age小于3岁的数据,接着按food.grade分组,
           并求出每组的平均年龄、以及输出每组第一个、最后一个、所有熊猫姓名,
           最后按照平均年龄升序排列 */
@Test
void testAggregation2() {
   
   
    // 1.组装过滤条件,food字段必须存在,并且age大于等于3
    Criteria criteria = Criteria.where("food").exists(true).and("age").gte(3);
    Aggregation aggregation = Aggregation.newAggregation(
            // 2.过滤阶段:传入构建好的条件对象
            Aggregation.match(criteria),
            // 3.分组阶段:按food.grade食物等级分组
            Aggregation.group("food.grade")
                    // 3.1. 求出age字段的平均值
                    .avg("age").as("avg_age")
                    // 3.2.拿到每组的第一个熊猫姓名
                    .first("name").as("first_name")
                    // 3.3.拿到每组的最后一个熊猫姓名
                    .last("name").as("last_name")
                    // 3.4.将所有熊猫姓名push到names数组
                    .push("name").as("names"),
            // 4.排序阶段:将分组后的数据,按平均年龄升序排列
            Aggregation.sort(Sort.Direction.ASC, "avg_age")
    );
    AggregationResults<Map> results = mongoTemplate.aggregate(aggregation, "xiong_mao", Map.class);
    List<Map> maps = results.getMappedResults();
    maps.forEach(System.out::println);
}
/* 执行结果:
    {_id=C, avg_age=3.0, first_name=粉熊, last_name=粉熊, names=[粉熊]}
    {_id=S, avg_age=3.5, first_name=黑熊, last_name=金熊, names=[黑熊, 金熊]}
    {_id=B, avg_age=4.0, first_name=白熊, last_name=白熊, names=[白熊]}
    {_id=A, avg_age=5.0, first_name=棕熊, last_name=黑黑, names=[棕熊, 紫熊, 黑黑]}
*/

代码和结果,大家可以花个一分钟仔细看看,每行都写了注释,结果也的确达到了起初的需求,大家可以试着多理解、多练习,自然而然就掌握了如何去写,最后再来一个案例吧:

/* 案例三:先过滤掉food为空的数据,基于food.grade分组,且保留原文档,并输出分组字段值、
    原文档的name、age字段、原文档在各分组中的下标,同时要支持分页功能 */
@Test
void testAggregation3() {
   
   
    // 1.每页的数据量为2条
    int pageSize = 2;
    // 2.过滤掉food字段为空的条件对象
    Criteria criteria = Criteria.where("food").exists(true);
    // 3.用for循环模拟三个分页请求
    for (int pageNumber = 1; pageNumber <= 3; pageNumber++) {
   
   
        Aggregation aggregation = Aggregation.newAggregation(
                // 4.过滤阶段:传入构建好的条件对象
                Aggregation.match(criteria),
                // 5.分组阶段:按food.grade分组,$$ROOT代表引用原文档,并放入pandas数组
                Aggregation.group("food.grade").push("$$ROOT").as("pandas"),
                // 6.拆分阶段:将前面每个分组的pandas数组拆成一个个文档(index:下标,true:防丢失)
                Aggregation.unwind("pandas", "index", true),
                // 7.投影阶段:去掉部分字段不显示,只显示_id(原food.grade)、name、age、index字段
                Aggregation.project("_id","pandas.name","pandas.age","index"),
                // 8.分页阶段:使用skip、limit来区分读取不同页的数据
                Aggregation.skip((pageNumber - 1) * pageNumber),
                Aggregation.limit(pageSize)
        );
        AggregationResults<Map> results = mongoTemplate.aggregate(aggregation, "xiong_mao", Map.class);
        System.out.println("第" + pageNumber + "页数据为:");
        List<Map> maps = results.getMappedResults();
        maps.forEach(System.out::println);
    }
}
/* 执行结果:
    第1页数据为:
    {_id=B, index=0, name=白熊, age=4.0}
    {_id=A, index=0, name=棕熊, age=3.0}
    第2页数据为:
    {_id=A, index=1, name=紫熊, age=3.0}
    {_id=A, index=2, name=黑黑, age=9}
    第3页数据为:
    {_id=S, index=1, name=英熊, age=2.0}
    {_id=S, index=2, name=金熊, age=4.0}
*/

这个案例需求是我瞎编的,没有任何逻辑可言,目的在于帮大家熟悉复杂场景下的API用法,代码的含义可参考注释,重点来说说执行结果,这个结果有些小伙伴可能看的有点懵,其实特别简单。_id代表原本的food.grade食物等级,而index则是原文档在各分组中的下标,因为原文档按顺序被压入了pandas数组,为啥会有重复值呢?

很简单,因为这个下标是以“分组”来区分的,上述B分组只有一个文档,所以index=0A分组中有三个文档,所以index=0、1、2,其他分组以此类推……,这样解释后,相信大家一定能看懂这个执行结果。

到这里,关于SpringData中如何使用聚合管道的案例,就暂且告一段落了,如果大家感觉还没有尽兴,可以自行回到《入门篇-聚合管道查询》阶段,将其中每个案例用Java代码实现一遍~

3.5、ExampleMatcher匹配器对象

前面讲完了聚合管道,接着来说说匹配器,这是SpringData家族都有的对象,用于在匹配查询条件时对查询对象进行更细粒度的匹配配置,我们可以通过它自定义匹配的行为,好比排除某些字段、只匹配某些字段等。

可能按上面这么说,估计大家也不太明白这玩意儿的作用,那么先上个简单例子,大家看完自然而然就明白了,如下:

@Test
void testExampleMatcher() {
   
   
    Panda panda = new Panda();
    panda.setName("黑黑");
    ExampleMatcher matcher = ExampleMatcher.matching()
            .withIgnoreCase()
            .withMatcher("name", ExampleMatcher.GenericPropertyMatchers.contains());
    Example<Panda> example = Example.of(panda, matcher);
    Criteria criteria = new Criteria().alike(example);
    List<Panda> pandas = mongoTemplate.find(Query.query(criteria), Panda.class);
    pandas.forEach(System.out::println);
}
/* 执行结果:
    Panda(id=111, name=黑黑, age=9, color=黑色, food=Food(name=黑晶竹, grade=A))
*/

上面是个使用ExampleMatcher的案例,从结果来看,无非就是基于name字段做查询,最终查到了“黑黑”这条数据,重点来看看匹配器的定义:

ExampleMatcher matcher = ExampleMatcher.matching()
        .withIgnoreCase()
        .withMatcher("name", ExampleMatcher.GenericPropertyMatchers.contains());

这是在定义匹配规则,withIgnoreCase()表示忽略大小写,withMatcher()表示具体的匹配逻辑,对于name字段的条件,会以contains()包含规则去匹配,大家可以尝试把案例中的“黑黑”替换为“黑”,执行时照样能匹配到“黑黑”这条数据,因为“黑黑”包含了“黑”。

经过上述解释,相信诸位应该大致清楚ExampleMatcher的作用了吧?它可以针对每个字段,自定义细粒度的匹配规则,ExampleMatcher提供了三个方法用于创建匹配器:

// 创建一个默认的ExampleMatcher实例(底层调用了matchingAll()方法)
static ExampleMatcher matching();
// 创建一个匹配所有属性(字段)的ExampleMatcher实例
static ExampleMatcher matchingAll();
// 创建一个匹配任意属性(字段)的ExampleMatcher实例
static ExampleMatcher matchingAny();

下面再来看看ExampleMatcher中,自定义匹配规则的方法:

// 为指定字段设置自定义的匹配规则
ExampleMatcher withMatcher(String propertyPath, GenericPropertyMatcher genericPropertyMatcher);
// 为字符串设置自定义的匹配规则
ExampleMatcher withStringMatcher(StringMatcher defaultStringMatcher);

// 设置匹配时忽略大小写
ExampleMatcher withIgnoreCase();
// 设置匹配时包含空值字段
ExampleMatcher withIncludeNullValues();
// 设置匹配时忽略空值字段
ExampleMatcher withIgnoreNullValues();
// 为指定字段设置“字段值转换器”
ExampleMatcher withTransformer(String propertyPath, PropertyValueTransformer
                                propertyValueTransformer);
// 设置匹配时要排除的字段值
ExampleMatcher withIgnorePaths(String... ignoredPaths);

OK,上面这些方法简单了解一下,看withMatcher()方法的入参,是一个GenericPropertyMatcher类型,而该类中提供了一些内置规则方法,比如前面的contains()包含规则,其他方法如下:

// 设置匹配时,指定字段的值,必须包含给定值
public GenericPropertyMatcher contains();
// 设置匹配时,指定字段的值,必须以给定值开头
public GenericPropertyMatcher startsWith();
// 设置匹配时,指定字段的值,必须以给定值结尾
public GenericPropertyMatcher endsWith();
// 设置匹配时,指定字段的值,必须与给定值完全匹配
public GenericPropertyMatcher exact();
// 设置匹配时,指定字段的值,必须符合给定的正则表达式
public GenericPropertyMatcher regex();
// 设置匹配时,指定字段的值会区分大小写
public GenericPropertyMatcher caseSensitive();
// 设置匹配时,指定字段的值不区分大小写
public GenericPropertyMatcher ignoreCase();

上面便是常用的匹配规则,了解这些后,再回到前面的案例加深理解:

// 1.创建了一个对象,用于承载每个字段的条件值
Panda panda = new Panda();
panda.setName("黑");

// 2.创建了一个匹配器
ExampleMatcher matcher = ExampleMatcher.matching()
        // 3.匹配规则一:忽略大小写
        .withIgnoreCase()
        // 4.匹配规则二:name字段必须包含前面指定的“黑”字
        .withMatcher("name", ExampleMatcher.GenericPropertyMatchers.contains());
// 5.创建出匹配器实例
Example<Panda> example = Example.of(panda, matcher);
// 6.将其传入到条件对象中
Criteria criteria = new Criteria().alike(example);
// 7.使用mongoTemplate查询数据,将带有匹配器的条件对象传入
List<Panda> pandas = mongoTemplate.find(Query.query(criteria), Panda.class);
// 8.打印查询后的结果
pandas.forEach(System.out::println);

加上这些注释后,大家是不是就能很好理解了?其实整体很简单,就是一种“另类”的条件查询,可以针对每个字段指定多条匹配规则。

好了,关于这玩意儿就此打住,其实ExampleMatcher用的也不是特别多,大家在这里了解即可,因为前面讲到的其他查询方式,足以满足日常开发中的所有需求,匹配器只有在某些特殊的业务场景下,才会使用。

四、MongoDB配置详解

到这里,前面已经将SpringDataMongoDB的大多数操作讲述完毕,掌握上述所有知识后,满足日常开发已不再是难事,接着来看看MongoDB的配置,这里是指application.yml文件中的配置,先上个配置文件:

spring:
  data:
    mongodb:
      host: 192.168.229.136
      port: 27017
      database: zhuzi
      username: zhuzi
      password: 123456
      # uri: mongodb://zhuzi:123456@192.168.229.137:27017/zhuzi
      authentication-database: zhuzi
      auto-index-creation: on

下面解释一下每个参数的作用:

  • host:部署MongoDB服务的机器IP
  • portMongoDB服务的端口号,默认为27017
  • database:当前项目连接的数据库;
  • username:连接MongoDB的账号;
  • password``MongoDB账号的密码;
  • uri:前五项的合集;
  • authentication-database:认证的数据库(连接用的账号,不在连接的库下时使用);
  • auto-index-creation:是否自动创建索引的配置;

这些即是常用的参数配置,基本上在之前都有接触过,所以不过多展开细说。

4.1、副本集配置

经过第二篇:《MongoDB集群篇》的学习,各位接触到了MongoDB副本集群,那SpringDataMongoDB如何配置成副本集模式呢?语法如下:

mongodb://username:password@[host:port1],[host:port2],[....]/database?options

这一段要写在uri里面,例如:

uri: mongodb://zhuzi:123456@192.168.229.137:27018,192.168.229.137:27019,192.168.229.137:27020/zhuzi?connect=replicaSet&slaveOk=true&replicaSet=zhuzi

其中的参数说明如下:

  • connect:连接模式,指定为replicaSet代表连接副本集群;
  • slaveOk:从节点是否可读,为true表示可读,执行语句时,读操作自动发往从节点;
  • replicaSet:副本集群的名称,这里为zhuzi

uri换成上述这个格式后,就配置好了副本集连接参数,并且SpringData会自动根据执行的操作,将请求分发到不同类型的节点,从而实现读写分离的效果(大家可以自行去执行原本的操作,来检测配置是否生效)。

4.2、分片集群配置

在上篇中还提到了分片集群,那SpringData中如何连接分片集群呢?这里语法类似,但不需要跟option参数,只需要配置所有mongos所在的IP、端口即可,如下:

uri: mongodb://zhuzi:123456@192.168.229.137:27024,192.168.229.137:27025/zhuzi

上篇中,27024、27025被咱们做成了路由节点,为此,这里配置这两个节点即可,配置完成后,SpringData会自动去将请求交给mongos节点,再由mongos根据配置的分片键、分片算法,将请求分发到具体的分片节点(具体测试大家可以自行去玩玩)。

4.3、多数据源整合

不知大家是否还记得,之前曾说过:虽然MongoDB支持事务,但其事务功能存在一定缺陷,那假设咱们实际开发过程中,某些业务又特别依赖事务怎么办?这是最好的做法是:将这些事务依赖项强的数据,还是存储在传统的关系型数据库里

啥意思呢?就比如MySQL、MongoDB一起使用,数据量较大、事务要求不高的数据,放入到MongoDB中;而事务要求较高、增量不算太快的业务数据,则依旧放在MySQL里面,怎么整合呢?其实很简单,把MySQL的驱动、连接池依赖引入进来,而后和之前一样配置即可,如下:

<!-- MySQL驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.28</version>
</dependency>
<!-- 连接池依赖 -->
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>4.0.3</version>
</dependency>

接着再配置一下数据库的信息:

spring:
  datasource:
    url: jdbc:mysql://.....
    username: zhuzi
    password: zhuzi
    driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.zaxxer.hikari.HikariDataSource
    hikari:
      minimum-idle: 0
      maximum-pool-size: 20
      idle-timeout: 10000
      auto-commit: true
      connection-test-query: SELECT 1

而后就可以实现多数据源操作啦,要存到MySQL的数据,就用之前的MyBatis、JPA、Hibernate等框架,实体类、Dao层按之前的逻辑编写。而要存到MongoDB中的数据,就按本文的方式编写实体类、repository层即可。

五、库内分表方案

好了,上一阶段几乎都是简单的配置,没有任何难点,因此没有刻意展开讲述,下面来看一个特殊场景,假设现在采用MongoDB存储客服的聊天记录,其他数据全部存到MySQL,假设这里每天有10W个客户会话,每个客户平均的消息对话数量为50条,稍微算算每日数据的增量:

100000 * 50 = 5000000(五百万条)

单日暴增500W数据,虽然MongoDB号称大数据存储组件,但目前仅有一个业务的数据存在里面,直接上分片集群显然大材小用了,该怎么存储?在之前的MySQL专栏,其实出过一章:《库内分表篇》,用来讲述该如何解决这类问题,同样的思想也可以用到MongoDB上,即:按照一定维度对数据进行分表(分集合)

5.1、数据拆分思路

常规的做法有两种,一种是按时间分,另一种是按数据量来划分,想要将数据分发到同一个库、不同集合中,最关键的在于如何计算出:一条数据究竟该落入哪个集合?后续操作时又能精准拿到?这个问题解决后,其他就不再是问题,下面展开讲讲。

相较于MySQL做库内分表,MongoDB实现起来的难度低许多许多,Why?大家回想一下前面MongoTemplate提供的方法:

insert(T objectToSave, String collectionName);
remove(Query query, String collectionName);
updateMulti(Query query, UpdateDefinition update, String collectionName);
find(Query query, String collectionName);
findAll(Query query, String collectionName);
......

大家观察这些方法的最后一个参数,会明显发现,增删改查方法都支持指定集合名称!也就意味着,我们只需要计算出集合名词,就能实现“库内分表”。同时,MongoDB由于其无模式特性,咱们也不需要提前创建表结构,每当数据量增长到一定程度时,就能自动使用一个新集合来存储。

当然,如果之前没阅读过《库内分表篇》的小伙伴,看起来或许会有点懵懂,因此建议大家可以去看看前面那篇文章,下面开始实操,这里先给出一个公用的实体类:

@Data
public class ZhuZi {
   
   
    private Long id;
    private String name;
}

5.2、按时间划分

按时间维度来分表(专业称呼叫分集合,但分表比较顺口,后续都以分表来称呼),首先得以时间字段作为分片键,这里也可以为时间戳,所以咱们首先来编写一个ID生成器,主要用于生成时间戳类型的ID

public class IdGenerator {
   
   
    /*
     * 获取时间戳ID(生产换成雪花ID,雪花ID包含时间戳,具备全局唯一性)
     * */
    public static long getTimeStampId() {
   
   
        return System.currentTimeMillis();
    }
}

这个主键生成器尤为简单,就是获取了一下当前的系统时间戳,但这样做并不专业,我仅仅是为了演示,图个方便,更好的做法是使用雪花ID算法,生成出来的ID会包含时间戳,并且可以保证全局唯一性(大家不想自己写,可以直接去拷贝MyBatisPlus里的雪花ID生成类)。

好了,有了上述这个简陋的ID生成器后,接着再来写动态分表的策略类,首先定义一个接口:

// 动态分表策略接口
public interface DynamicCollection<T> {
   
   
    /*
    * 获取目标集合名的方法
    * */
    String getTargetCollectionName(T criteria);
}

这个接口实则非常简单,里面就定义了一个“获取目标集合(表)名”的方法,为啥要定义这个接口呢?因为可能会存在多种分片策略,为此,先抽象出一个公共的接口来定义分片行为。再来看方法的入参,它是一个泛型,这里主要用于指定分片键的类型,下面来看具体的实现:

// 根据日期时间分表的实现类
public class DateTimeDynamicCollection implements DynamicCollection<Long> {
   
   
    // 日期格式化对象
    private static final ThreadLocal<SimpleDateFormat> monthTL =
            ThreadLocal.withInitial(() ->
                    new SimpleDateFormat("yyyyMMdd"));
    // 集合名的前缀
    private static final String collectionPrefix = "zhuzi_";

    /**
     *  根据时间戳获取目标集合名(每天的数据归档到一个集合)
     * @param timeStamp 时间戳
     * @return 集合名
     */
    @Override
    public String getTargetCollectionName(Long timeStamp) {
   
   
        Date date = new Date(timeStamp);
        return collectionPrefix + (monthTL.get().format(date));
    }
}

这是一个根据日期时间分表的实现类,入参为一个时间戳,接着内部会将时间戳转为yyyyMMdd格式的字符串,最后拼接上对应的前缀名,然后对外返回出“目标表名”,相当于按“天”分表,是不是特别简单?答案是Yes,毕竟这只是个Demo,旨在讲清如何实现动态分表。

接着来编写测试用例,如下:

@Autowired
private MongoTemplate mongoTemplate;

@Test
void testDynamicCollectionByDateTime(){
   
   
    ZhuZi zhuZi = new ZhuZi();
    long id = IdGenerator.getTimeStampId();
    zhuZi.setId(id);
    zhuZi.setName("竹子一号");

    // 这里最好做成工具类或者单例Bean,使用时不需要new,而是直接用
    DynamicCollection<Long> dynamic = new DateTimeDynamicCollection();
    mongoTemplate.insert(zhuZi, dynamic.getTargetCollectionName(id));
}

这个用例特简单,先获取了一下当前时间戳作为主键,接着再通过生成的ID,得到了要插入的目标表名,最后通过mongoTemplate.insert()方法,将这条数据插入到了对应的集合中,下面去MongoDB看看:

002.png

如上图所示,MongoDB中的确多了一个zhuzi_20230918的集合,并且“竹子一号”这条数据,也成功插入到了该集合中,接着再来换一个时间戳试试看,如下:

@Test
void testDynamicCollectionByDateTime() throws ParseException {
   
   
    ZhuZi zhuZi = new ZhuZi();
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    long timeStamp = sdf.parse("2023-11-11 11:11:11").getTime();
    zhuZi.setId(timeStamp);
    zhuZi.setName("竹子二号");

    DynamicCollection<Long> dynamic = new DateTimeDynamicCollection();
    mongoTemplate.insert(zhuZi, dynamic.getTargetCollectionName(zhuZi.getId()));
}

这里咱们手动创建了一个时间为2023-11-11 11:11:11Date对象,并将其转变为了时间戳,接着依旧和之前代码相差不大,直接来看结果:

003.png

效果十分明显,此时又多出了一个zhuzi_20231111集合,并且“竹子二号”也被插入到了其中,此时动态分表的目的已然达成,那其他操作又该怎么办呢?比如查询、修改、删除……,很简单,基于目前的分片键:id,去对数据进行操作即可,例如查询:

// 这里复制了之前插入的时间戳
long id = 1699672271000L;
DynamicCollection<Long> dynamic = new DateTimeDynamicCollection();
String target = dynamic.getTargetCollectionName(id);
Query query = Query.query(Criteria.where("_id").is(id));
ZhuZi zhuzi = mongoTemplate.findOne(query, ZhuZi.class, target);
System.out.println(zhuzi);

/* 执行结果:
    ZhuZi(id=1699672271000, name=竹子二号)
*/

效果很明显,查询到了“竹子二号”这条数据,好了,对于修改、删除操作就不再演示,过程都类似,记住这里的核心思想:每次操作必须传入分片键,因为需要通过分片键来计算表名,从而才能操作对应的表,如果没有分片键字段,就只能对所有集合全部执行一次相应操作

以上述案例来说,如果要基于name字段查询数据,就无法解析出“目标集合名”,只能对所有集合发起查询,而后再对数据进行汇总,如果集合数量过多,假设有100个,此时的执行效率堪忧,而这样的问题,在Sharding-Sphere这样的框架中,为了提升性能,底层会采用多线程去并发查询,大家也可以借鉴这点思想,个人不再继续完善。

5.3、按数据量划分

前面讲了按时间分表,现在再来说说业内另一种常见的分片策略,即按数据量划分,例如一千万条数据为一张表,怎么做呢?首先你的数据中,必须存在一个记录当前行数的值,这里可以选用自增ID来实现,毕竟自增ID天生具备这个功能,为此,这里再在之前的ID生成器中新增一个方法:

// 原子长整型自增器
private static final AtomicLong ATOMIC_LONG = new AtomicLong(1);

/*
* 获取自增ID(生产换成特定的原子累加器,如MySQL自增、Redis自增)
* */
public static long getIncrId() {
   
   
    return ATOMIC_LONG.incrementAndGet();
}

这里依旧是Demo模式,专业做法并不能依靠AtomicLong来维护自增序列,因为服务一旦重启,这个计数器会清零重新开始,就会和之前自增生成的序列产生冲突,所以最好选用第三方中间件来维护自增序列,如数据库、Redis……啥的都行。

咱们目前不考虑这么多,能出效果就行,这里再写一个按数量量分片的策略实现类:

// 根据数据量分表的实现类
public class DataVolumeDynamicCollection implements DynamicCollection<Long> {
   
   
    // 集合名的前缀
    private static final String collectionPrefix = "zhuzi_";

    /**
     * 根据自增ID获取目标集合名(每一千万条数据归档到一个集合)
     * @param incrValue 自增ID
     * @return 集合名
     */
    @Override
    public String getTargetCollectionName(Long incrValue) {
   
   
        return collectionPrefix + (incrValue / 10000000);
    }
}

又是一个特别简单的实现过程,这里会通过外部生成的自增ID,对1000W求商(相除),因为Java/运算,并不会得到余数,所以这里会返回一个整数,例如:

1 / 10000000 = 0;
10000001 / 10000000 = 1;
20000001 / 10000000 = 2;

而后直接与定义好的前缀拼接,从而得到的目标表名。当然,这里的表前缀名是完全一样的,最好的做法是将这个重复的定义,抽象为一个常量类,或者枚举类,方便于后续维护,并且能减少冗余代码,感兴趣的小伙伴可以自己去改造一下~

OK,下面直接来写单元测试,看看效果:

@Test
void testDynamicCollectionByDataVolume(){
   
   
    ZhuZi zhuZi = new ZhuZi();
    long id = IdGenerator.getIncrId();
    zhuZi.setId(id);
    zhuZi.setName("一号竹子");

    DynamicCollection<Long> dynamic = new DataVolumeDynamicCollection();
    mongoTemplate.insert(zhuZi, dynamic.getTargetCollectionName(id));
}

代码不解释了,和之前近乎一模一样,唯一区别就在于换了实现类,直接上效果图:

004.png

可以看到,这里又多出了一个zhuzi_0集合,并且“一号竹子”也被插入到了其中,至于其他测试就不再重复叙述了,大家可以试着手动分配一个值为10000001LID,同样能观察到zhuzi_1集合的出现……。与前面的时间分片策略类似,基于数据量分片后,无论任何操作都需要传入分片键字段,如果不传递,则会导致无法计算出目标表名……。

PS:这也是数据分片存在的通病,不管是传统的分库分表,还是例如TiDB这样的分布式数据库,又或者上篇所提到的MongoDB分片集群,都存在这样的缺陷,一旦数据被分片存储,操作数据时就必须传入分片键,否则只能对所有分片节点都执行一遍操作。

五、SpringData-MongoDB篇总结

好了,叨叨絮絮了一大堆,本篇核心是:Java如何基于SpringData-MongoDB框架,平滑接入MongoDB作为数据存储服务,我们一点点从基础入门,到常用API、进阶API、配置详解、高级用法……,逐步进行了全面阐述,相信认真看完本文的你,在项目中需要使用MongoDB时,一定会显得如鱼得水~

最后,这里也总结一下本文提到的四种CRUD模式:

  • ①基于MongoRepository提供的命名约束,自定义方法实现基本增删改查;
  • ②基于注解形式,编写原生语句实现特定的增删改查;
  • ③基于MongoTemplate编写定制化的增删改查;
  • ④基于ExampleMatcher进行复杂的增删改查;

其中最常用的是①、③,至于其他两种近乎不用,大家心里知道就行,毕竟①、③几乎能满足日常开发中的所有需求~

相关实践学习
MongoDB数据库入门
MongoDB数据库入门实验。
快速掌握 MongoDB 数据库
本课程主要讲解MongoDB数据库的基本知识,包括MongoDB数据库的安装、配置、服务的启动、数据的CRUD操作函数使用、MongoDB索引的使用(唯一索引、地理索引、过期索引、全文索引等)、MapReduce操作实现、用户管理、Java对MongoDB的操作支持(基于2.x驱动与3.x驱动的完全讲解)。 通过学习此课程,读者将具备MongoDB数据库的开发能力,并且能够使用MongoDB进行项目开发。 &nbsp; 相关的阿里云产品:云数据库 MongoDB版 云数据库MongoDB版支持ReplicaSet和Sharding两种部署架构,具备安全审计,时间点备份等多项企业能力。在互联网、物联网、游戏、金融等领域被广泛采用。 云数据库MongoDB版(ApsaraDB for MongoDB)完全兼容MongoDB协议,基于飞天分布式系统和高可靠存储引擎,提供多节点高可用架构、弹性扩容、容灾、备份回滚、性能优化等解决方案。 产品详情: https://www.aliyun.com/product/mongodb
相关文章
|
10天前
|
JSON Java Apache
非常实用的Http应用框架,杜绝Java Http 接口对接繁琐编程
UniHttp 是一个声明式的 HTTP 接口对接框架,帮助开发者快速对接第三方 HTTP 接口。通过 @HttpApi 注解定义接口,使用 @GetHttpInterface 和 @PostHttpInterface 等注解配置请求方法和参数。支持自定义代理逻辑、全局请求参数、错误处理和连接池配置,提高代码的内聚性和可读性。
|
30天前
|
算法 Java 数据处理
从HashSet到TreeSet,Java集合框架中的Set接口及其实现类以其“不重复性”要求,彻底改变了处理唯一性数据的方式。
从HashSet到TreeSet,Java集合框架中的Set接口及其实现类以其“不重复性”要求,彻底改变了处理唯一性数据的方式。HashSet基于哈希表实现,提供高效的元素操作;TreeSet则通过红黑树实现元素的自然排序,适合需要有序访问的场景。本文通过示例代码详细介绍了两者的特性和应用场景。
40 6
|
30天前
|
存储 Java
深入探讨了Java集合框架中的HashSet和TreeSet,解析了两者在元素存储上的无序与有序特性。
【10月更文挑战第16天】本文深入探讨了Java集合框架中的HashSet和TreeSet,解析了两者在元素存储上的无序与有序特性。HashSet基于哈希表实现,添加元素时根据哈希值分布,遍历时顺序不可预测;而TreeSet利用红黑树结构,按自然顺序或自定义顺序存储元素,确保遍历时有序输出。文章还提供了示例代码,帮助读者更好地理解这两种集合类型的使用场景和内部机制。
38 3
|
30天前
|
存储 Java 数据处理
Java Set接口凭借其独特的“不重复”特性,在集合框架中占据重要地位
【10月更文挑战第16天】Java Set接口凭借其独特的“不重复”特性,在集合框架中占据重要地位。本文通过快速去重和高效查找两个案例,展示了Set如何简化数据处理流程,提升代码效率。使用HashSet可轻松实现数据去重,而contains方法则提供了快速查找的功能,彰显了Set在处理大量数据时的优势。
33 2
|
1月前
|
存储 算法 Java
Java Set因其“无重复”特性在集合框架中独树一帜
【10月更文挑战第14天】Java Set因其“无重复”特性在集合框架中独树一帜。本文深入解析Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定的数据结构(哈希表、红黑树)确保元素唯一性,并提供最佳实践建议,包括选择合适的Set实现类和正确实现自定义对象的`hashCode()`与`equals()`方法。
30 3
|
19天前
|
人工智能 前端开发 Java
基于开源框架Spring AI Alibaba快速构建Java应用
本文旨在帮助开发者快速掌握并应用 Spring AI Alibaba,提升基于 Java 的大模型应用开发效率和安全性。
基于开源框架Spring AI Alibaba快速构建Java应用
|
19天前
|
消息中间件 Java 数据库连接
Java 反射最全详解 ,框架设计必掌握!
本文详细解析Java反射机制,包括反射的概念、用途、实现原理及应用场景。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
Java 反射最全详解 ,框架设计必掌握!
|
27天前
|
前端开发 Java 数据库连接
Spring 框架:Java 开发者的春天
Spring 框架是一个功能强大的开源框架,主要用于简化 Java 企业级应用的开发,由被称为“Spring 之父”的 Rod Johnson 于 2002 年提出并创立,并由Pivotal团队维护。
43 1
Spring 框架:Java 开发者的春天
|
25天前
|
SQL Java 关系型数据库
java连接mysql查询数据(基础版,无框架)
【10月更文挑战第12天】该示例展示了如何使用Java通过JDBC连接MySQL数据库并查询数据。首先在项目中引入`mysql-connector-java`依赖,然后通过`JdbcUtil`类中的`main`方法实现数据库连接、执行SQL查询及结果处理,最后关闭相关资源。
|
21天前
|
缓存 Java 数据库连接
Hibernate:Java持久层框架的高效应用
通过上述步骤,可以在Java项目中高效应用Hibernate框架,实现对关系数据库的透明持久化管理。Hibernate提供的强大功能和灵活配置,使得开发者能够专注于业务逻辑的实现,而不必过多关注底层数据库操作。
12 1