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

引言

前面的两篇文章已经将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提供的众多命令,那么,下面开始吧~

PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选Offer方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职......,为大家打造了一套"从求职到跳槽"的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE,感兴趣的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!

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

一、SpringData-MongoDB入门

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

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

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文件,如下:

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的依赖:

xml 复制代码
<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集合相映射的实体类,如下:

java 复制代码
@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三层,目前仅是为了测通,为此,只编写持久层和业务层,这里先写前者:

java 复制代码
@Repository
public interface PandaRepository extends MongoRepository<Panda, Integer> {}

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

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

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

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

java 复制代码
@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()方法看看:

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

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

java 复制代码
@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工具编写方法时,它也会自动提示你!这里随便来定义几个方法,帮助诸位加深理解:

java 复制代码
@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方法,如下:

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

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

java 复制代码
// 在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层后,为了测试分页功能是否正常,这里再编写一个单元测试:

java 复制代码
@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对象中,拿到了总页数、总行数,以及具体的分页数据,输出结果如下:

java 复制代码
总页数: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

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

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

java 复制代码
// 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,下面看看测试结果:

java 复制代码
// 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中该如何使用事务呢?如下:

java 复制代码
// 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对象,所以要记得注入该对象,如下:
java 复制代码
@Autowired
private MongoClient mongoClient;

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

java 复制代码
@Test
void testMongoTransaction() {
    pandaService.mongoTransaction();
}

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

java 复制代码
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中提供了不同的方法,用于执行不同类型的操作,首先来看看插入方法:

java 复制代码
// 插入一个文档(自动根据对象类型解析目标集合)
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会自动解析。但如果你想将一个字段完全不同的文档插入到某个集合,则可以显式指定集合名称。

接着来看看删除方法:

java 复制代码
// 删除一个文档(根据入参类型自动解析目标集合)
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,可以直接传入一个对象进行删除,也可以根据条件进行删除,没啥好讲的,接着看看修改方法:

Java 复制代码
// 更新指定的文档
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中最多的一类:

java 复制代码
// 查询指定集合里是否存在满足条件的数据
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还提供了执行聚合管道命令的方法,如下:

java 复制代码
// 在指定集合中,执行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语法,比如你想实现一个条件查询,可以这样写:

java 复制代码
// 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);
}

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

java 复制代码
Query query = Query.query(Criteria.where("color").is(color));

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

java 复制代码
@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对象,各方法的作用如下:

java 复制代码
// 定义条件查询对象
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类中实则定义了几十个方法,但较为常用的则是上面列出的这几个,通常咱们理解上面这几个方法的作用即可,下面结合前面的案例,简单过一下这些方法:

java 复制代码
// 一、定义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);
}

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

java 复制代码
/* 集合内符合查询条件的数据如下:
    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类:

java 复制代码
// 仅有的构造器,使用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内部类如下:

java 复制代码
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对象中,如下:

java 复制代码
query.with(Sort.by(Sort.Order.desc("_id")));

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

java 复制代码
// 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类中的常用方法,这里主要关心前面两个方法,一个是指定需要返回的字段列表,另一个是指定需要排除的字段列表,如下:

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

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

3.2.2、Criteria条件对象详解

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

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

java 复制代码
Query query = Query.query(Criteria.where("color").is(color));
query.addCriteria(Criteria.where("age").is(age));

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

java 复制代码
// 过滤条件的起始方法(类似于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方法也是相同的含义,将各种条件表达式,以"方法命名规范"的形式来编写。

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

java 复制代码
// 查询所有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类的定义:

java 复制代码
public class Update implements UpdateDefinition {}

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

java 复制代码
// 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对象则用来承载要修改的数据,来看看输出结果:

java 复制代码
/*
    修改前的数据: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对象的用法后,接着来看看其中提供的方法:

java 复制代码
// 将指定字段的值,修改为给定值
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过来一个原生的聚合管道命令,如下:

JavaScript 复制代码
/* 按年龄进行分组,并统计各组的数量(没有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代码实现呢?如下:

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里的意思也是相同的,毕竟方法名字都没改,和操作符的名称一模一样,下面来些案例玩玩:

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字符串输出的过程。下面再来一个稍微复杂的案例看看:

java 复制代码
/* 案例二:过滤掉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=[棕熊, 紫熊, 黑黑]}
*/

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

java 复制代码
/* 案例三:先过滤掉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家族都有的对象,用于在匹配查询条件时对查询对象进行更细粒度的匹配配置,我们可以通过它自定义匹配的行为,好比排除某些字段、只匹配某些字段等。

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

java 复制代码
@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字段做查询,最终查到了"黑黑"这条数据,重点来看看匹配器的定义:

java 复制代码
ExampleMatcher matcher = ExampleMatcher.matching()
        .withIgnoreCase()
        .withMatcher("name", ExampleMatcher.GenericPropertyMatchers.contains());

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

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

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

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

java 复制代码
// 为指定字段设置自定义的匹配规则
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()包含规则,其他方法如下:

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

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

java 复制代码
// 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配置详解

PS:前面将SpringDataMongoDB的大多数操作都已经阐述完毕,还剩下一些配置解析、特殊场景下的解决方案没有写完,而要写的大体内容已经列出,由于目前时间不多,所以过两天再来补齐(这里先发出来,卡个金石活动的奖励,毕竟前面的内容已经有三万多字啦)~

4.1、副本集配置

4.2、分片集群配置

4.3、库内分表方案

4.4、多数据源整合

五、SpringData-MongoDB篇总结

  • ①基于MongoRepository提供的命名约束,自定义方法实现基本增删改查;
  • ②基于注解形式,编写原生语句实现特定的增删改查;
  • ③基于MongoTemplate编写定制化的增删改查;
  • ④基于ExampleMatcher进行复杂的增删改查;
相关推荐
顾北川_野几秒前
Android 手机设备的OEM-unlock解锁 和 adb push文件
android·java
江深竹静,一苇以航3 分钟前
springboot3项目整合Mybatis-plus启动项目报错:Invalid bean definition with name ‘xxxMapper‘
java·spring boot
confiself18 分钟前
大模型系列——LLAMA-O1 复刻代码解读
java·开发语言
Wlq041523 分钟前
J2EE平台
java·java-ee
小码的头发丝、26 分钟前
Django中ListView 和 DetailView类的区别
数据库·python·django
XiaoLeisj30 分钟前
【JavaEE初阶 — 多线程】Thread类的方法&线程生命周期
java·开发语言·java-ee
Karoku06636 分钟前
【企业级分布式系统】Zabbix监控系统与部署安装
运维·服务器·数据库·redis·mysql·zabbix
豪宇刘1 小时前
SpringBoot+Shiro权限管理
java·spring boot·spring
Elaine2023911 小时前
02多线程基础知识
java·多线程
gorgor在码农1 小时前
Redis 热key总结
java·redis·热key