【主流技术】MongoTemplate与Spring Boot项目集成分享(附CURD技巧)

发布时间 2023-09-25 09:52:38作者: Apluemxa

前言

MongoDB 是一个介于关系数据库和非关系数据库之间的产品,是非关系数据库当中功能最丰富、最像关系数据库的。

它支持的数据结构非常松散,是类似 Json 的 Bson(Json 的二进制)格式,因此可以存储比较复杂的数据类型。

而 MongoTemplate 是Spring Data MongoDB 中的一个核心类,为 Spring 与 MongoDB 数据库的交互提供了丰富的功能集。

MongoTemplate 提供了创建、更新、删除和查询 MongoDB 文档的便利操作,并提供了编程语言的领域对象(POJO)和 MongoDB 文档之间的映射。

一、表结构特点

在介绍 MongoTemplate 的使用之前,非常有必要再介绍一下 MongoDB 的表结构特点,我们所有的增删改查操作,都是基于其结构特点而进行的。

掌握了其结构特点,我们就能更好地使用 MongoTemplate 对数据进行操作了。

1.1Json格式

在 MongoDB 中,一条数据的记录(文档)格式是类似于 json的 格式,即强调 key-value 的关系。

MongoDB MySQL
数据库(database) 数据库(database)
集合(collection) 表(table)
文档(document) 行(row)

由“_id”字段为唯一主键代表了整条记录,这个主键只要创建时就会自动生成。

下面,用一张员工表来说明其具体的结构和 Java 实体对象之间的映射关系。

就像下面的 Json 格式一样,最外层的 key 代表的就是集合的字段,那么这个集合有 5 个字段,分别是:"_id"、"name"、"birth"、"dept"、"projects"。

{
    "_id": "962011536",
    "name": "Alex",
    "birth": {
        "age": "23",
        "birthDay": "2000-10-01"
    },
    "dept": {
        "deptId": "00201",
        "deptName": "技术研究院"
    },
    "projects": [
        {
            "name": "日志系统",
            "role": "engineer",
            "status": "COMPLETED",
            "director": "Mr.Zhang"
        },
        {
            "name": "OA系统",
            "role": "engineer",
            "status": "PROGRESSING",
            "director": "Mr.Luo"
        }
    ]
}

1.2实体映射

首先引入依赖:

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

对于 Java 的数据库-实体对象映射而言,集合字段名即为实体类属性名,但是注意驼峰命名或者下划线命名:

@Data
@Builder
@Api("员工信息表")
@TableName(value = "worker")
@EqualsAndHashCode(callSuper = true)
public class Worker extends BaseEntity implements Serializable {

    /**
     * 主键Id
     */
    @Id
    private String id;

    /**
     * 姓名
     */
    private String name;

    /**
     * 生日信息
     */
    private Birth birth;

    /**
     * 所属部门信息
     */
    private Dept dept;

    /**
     * 所参与项目信息
     */
    private List<Projects> projects;
}

相信大家已经发现了,实体类属性只与最外层的集合字段一一对应(这个非常重要)至于某个字段内嵌套的子key,或者子Key的子key,MongoTemplate 有其它的方式其进行数据操作。


二、条件构造

我们在使用 MongoTemplate 的时候,经常需要构造各种各样的条件来满足逻辑的需要,所以对条件的构造也是我们掌握 MongoTemplate 的基础之一。

2.1Criteria与Query的区别

Criteria 是一个查询对象,它包含了条件的查询参数;

Query 则是一个条件装载对象,完成具体的条件、映射、排序等查询操作;

使用 MongoTemplate 进行查询时,需要两者配合使用,缺一不可。

举个简单的例子:查出员工表中姓名为"Alex"的员工信息。

    /**
     * 简单条件语句
     */
    public Worker testSimple(){
        // 此处where()中的内容是 Java 方法引用的转换,因为where()中需要使用的是 String 类型的 key
        // 实则与where("name")没有区别,即Criteria.where("name").is("Alex")
        Query query = new Query(Criteria.where(fn.fnToFieldName(Worker::getName)).is("Alex"));
        return this.mongoTemplate.findOne(query, Worker.class);
    }

2.2简单条件

下面介绍一些简单的查询条件情景,以及适合该场景使用的关键字 demo。

  • 只查询一张表的某一个字段:include(包含该字段)
    /**
     * 只需返回一个id字段
     * @return 只包含工号字段的list
     */
    public List<Worker> testSingleField(){
        Query query = new Query();
        query.fields().include(FnConverter.of(Worker.class).fnToFieldName(Worker::getId));
        return this.mongoTemplate.find(query, Worker.class);
    }
  • 多个常用逻辑判断条件:ne(不等于),and(且),or(或),is(等于)
    /**
     * 多个简单条件:查询姓名不等于张三,且生日在2000年10月1号的所有员工信息
     * @return 员工信息的list
     */
    public List<Worker> testSimpleCondition(){
        Query query = new Query(Criteria
                .where(fn.fnToFieldName(Worker::getName)).ne("张三")
                .and(fn.fnToFieldName(Worker::getBirth)).is("2000-10-01"));
        return this.mongoTemplate.find(query,Worker.class);
    }
  • 多个条件判断操作符:in(包含),exists(是否存在)
    /**
     * 多个条件判断:判断是否有满足“ 1、要查询的工号在一个set集合中,2、且工号字段有值” 这样条件的记录
     * @param ids
     * @return 有则true,无则false
     */
    public Boolean testSimpleCondition2(Set<String> ids){
        //外层的exists()作用为判断整个条件的正确与否(即是否存在符合条件的记录),内层的exists()作用为判断该字段是否存在值
        return this.mongoTemplate.exists(Query.query(Criteria
                        .where(fn.fnToFieldName(Worker::getId)).in(ids)
                        .and(fn.fnToFieldName(Worker::getId)).exists(Boolean.TRUE)), Worker.class);
    }
  • 模糊查询:regex(正则匹配)
    /**
     * 模糊查询,只对 String 类型有效,且支持正则匹配格式
     * @param str
     * @return
     */
    public List<Worker> testSimpleRegex(String str){
        Query query = new Query(Criteria
                .where(fn.fnToFieldName(Worker::getName)).regex(str));
        return mongoTemplate.find(query, Worker.class);
    }

2.3复杂条件

复杂的条件一般会出现在与子 key 相关的场景中,这个子 key 的 value 形式,可能是 String,可能是 List ,也可能是 JSONObject,可以是 MongoDB 支持的所有数据结构。

    /**
     * 复杂条件构造:员工姓名不等于Tom且为“在职”状态,同时该员工参与过的项目状态为“已立项”,且项目负责人为Luo,最后按照员工的生日DESC顺序排列
     * @param someDTO
     * @param worker
     * @return
     */
    public Query builderQuery(SomeDTO someDTO, Worker worker){
        // 初始化条件构造器
        Query query = new Query();
        // 初始化多个 criteria 对象,形成一个 list 集合
        ArrayList<Criteria> criteriaList = new ArrayList<>();
        if (StringUtils.isNotBlank(someDTO.getId())){
            // 往该 criteriaChain 中增加一些条件
            query.addCriteria(new Criteria().and(fn.fnToFieldName(Worker::getName)).ne("Tom"));
            // 简单条件的不同写法
            query.addCriteria(Criteria.where(fn.fnToFieldName(Worker::getStatus)).is(Boolean.TRUE));
            //此处 elemMatch()会精确查询符合条件的嵌套数组内的某个元素
            // andOperator() 解决的是查询的是同一个字段多个约束的问题
            criteriaList.add(new Criteria().andOperator(Criteria
                    .where(FnConverter.of(Worker.class).fnToFieldName(Worker::getProjects)).elemMatch(Criteria
                            .where(FnConverter.of(Projects.class).fnToFieldName(Projects::getProjectStatus)).is(ProjectStatusEnum.APPROVAL)
                            .and(FnConverter.of(Projects.class).fnToFieldName(Projects::getDirector)).regex("Luo"))));
        }
        if (CollectionUtils.isNotEmpty(criteriaList)) {
            query.addCriteria(new Criteria().andOperator(criteriaList));
        }
        // 按照某一字段排序
        query.with(Sort.by(FnConverter.of(Birth.class).fnToFieldName(Birth::getBirthDay)).descending());
        return query;
    }

注:elemMatch()此方法会查询符合指定条件的嵌套数组元素,用来精确匹配数组内的某个元素,而不是整个数组。


三、如何选用接口

MongoRepository 的源码位置:

package org.springframework.data.mongodb.repository;

MongoTemplate 源码位置:

package org.springframework.data.mongodb.core;

3.1MongoRepository

MongoRepository 接口是 Spring Data 的一个核心接口,自己定义的接口 XxxxRepository 需要继承 MongoRepository,这样的 XxxxRepository 接口就具备了通用的数据访问控制层的能力(CURD的操作功能)。

MongoRepository 提供了最基本的数据访问功能,其几个子接口则扩展了一些功能。它们的继承关系如下(源码中可以找到):

Repository: 仅仅是一个标识,表明任何继承它的均为仓库接口类
CrudRepository: 继承 Repository,实现了一组 CRUD 相关的方法 
PagingAndSortingRepository: 继承 CrudRepository,实现了一组分页排序相关的方法 
MongoRepository: 继承 PagingAndSortingRepository,实现一组 mongodb 规范相关的方法

3.2MongoTemplate

MongoTemplate 是数据库和代码之间的接口,对数据库的操作都在它里面,总体上的使用与 Mybatis 类似。

MongoTemplate 核心操作类:Criteria 和 Query。

Criteria类:封装所有的语句,以方法的形式查询。
Query类:将语句进行封装或者添加排序之类的操作。

以下是子接口的继承关系:

1.MongoTemplate 实现了interface MongoOperations。
2.MongoDB documents和domain classes 之间的映射关系是通过实现了 MongoConverter 这个 interface 的类来实现的。
3.MongoTemplate 提供了非常多的操作 MongoDB 的方法。 它是线程安全的,可以在多线程的情况下使用。
4.MongoTemplate 实现了 MongoOperations 接口, 此接口定义了众多的操作方法如"find", "findAndModify", "findOne", "insert", "remove", "save", "update" and "updateMulti"等。
5.MongoTemplate 转换domain object 为 DBObject, 缺省转换类为 MongoMappingConverter,并提供了Query, Criteria, and Update 等流式API。

3.3两者对比

两者相比较而言,MongoRepository 的缺点是不够灵活,可直接操作的方法较少,但可能很适合用于一些简单的 CURD 场景。

而 MongoTemplate 的优点就很明显了:全面,可以用来作为 MongoRepository 的补充,能干 MongoDB 在 Spring 项目里所有的活儿。

如果对 mongodb 本身比较熟悉的话,你会发现 MongoTemplate 在应对复杂情况时能更加游刃有余一些,本质上是因为它更加接近原生的 mongodb 命令。


四、常见API

操作 API 前,需要先注入以下 2 个bean:

@Resource
private MongoTemplate mongoTemplate;

@Resource
private MongoRepository mongoRepository;

4.1增·insert

2 种insert 插入数据库方式:

this.mongoTemplate.insert(worker);
this.mongoRepository.insert(worker);

insert() 与 save() 的区别:

  • 使用save(),如果原来的对象不存在,那这两者都可以向 collection 里插入数据;如果已经存在,save() 会调用 update() 去更新里面的记录;

  • 使用 insert() 可以一次性插入一条记录,而不需要遍历,效率高; 而save() 则需要遍历,一个个对比插入;

所以如果是可以明确新增唯一一条记录的操作,尽量使用 insert() 方法。

4.2删 · delete

注:在删除记录前,需要先确认一下该记录是否存在:

目前已知有 2 种删除记录的方法,都是永久地从集合中移除文档(物理删除):

//根据主键id删除一条记录        
this.mongoRepository.deleteById("主键id");

//传入多个主键id,遍历删除
ArrayList<String> list = new ArrayList<>();
list.add("多个主键id");
this.mongoRepository.deleteAllById(list);
    /**
     * remove 删除所有符合条件的记录
     */
    public Boolean remove(SomeDTO someDTO){
        Worker worker = this.mongoTemplate.findById(someDTO.getId(), Worker.class);
        DeleteResult deleteResult = this.mongoTemplate.remove(worker);
        Assert.isTrue(deleteResult.getDeletedCount() == NumberUtils.LONG_ONE, "删除失败!");
        return Boolean.TRUE;
        //或者以下写法也可以
//        Query query = Query.query(Criteria
//                .where(this.fn.fnToFieldName(Worker::getId)).is(someDTO.getId()));
//        Assert.hasText(Objects.requireNonNull(this.mongoTemplate.findAndRemove(query, Worker.class)).getId(), "删除失败!");
//        return Boolean.TRUE;
    }

delete() 与 remove() 的区别:

remove() 是一个在项目中不推荐使用的方法,可以被deleteById() 和deleteAllById() 代替。

4.3改 · update

4.3.1update() 与 save() 的区别

update() 方法用于更新已存在的(确定的)文档,更新的是文档内的值,不改变该文档的数据结构;

save() 方法通过传入的文档来替换已有文档,关键的是文档里唯一key是主键id,如果表中存在此主键id的数据,则更新,否则就插入。

4.3.2update相关方法
  • updateFirst

    UpdateFirst 只会更新第一个匹配到的文档,而不会新建文档。如果找不到匹配的文档,updateFirst 不会做任何操作。

    Query query = Query.query(Criteria.where(this.fn.fnToFieldName(Worker::getId)).is(someDTO.getId()));
    Update update = new Update().set(this.fn.fnToFieldName(Worker::getName), "Tom");
    this.mongoTemplate.updateFirst(query, update, Worker.class);
    
  • updateMulti

    updateMulti 更新匹配条件的全部数据,同样地,如果找不到匹配的文档,updateMulti 不会做任何操作。

    Query query = Query.query(Criteria.where(this.fn.fnToFieldName(Worker::getStatus)).is(Boolean.TRUE));
    Update update = new Update().set(this.fn.fnToFieldName(Worker::getName), "Tom");
    this.mongoTemplate.updateMulti(query, update, Worker.class);
    
  • upsert

    upsert 会在找不到文档的情况下,将 update 的内容插入到文档中。例如,如果使用 upsert 去更新一个不存在的文档,MongoDB 会新建一个文档并将 update 的内容插入到新文档中。

4.3.3嵌套结构的更新

文档数据结构如下:

{
    "_id": "962011536",
    "name": "Alex",
    "birth": {
        "age": "23",
        "birthDay": "2000-10-01"
    },
    "dept": {
        "deptId": "00201",
        "deptName": "技术研究院"
    },
    "projects": [
        {
            "name": "日志系统",
            "role": "engineer",
            "status": "COMPLETED",
            "director": "Mr.Zhang"
        },
        {
            "name": "OA系统",
            "role": "engineer",
            "status": "PROGRESSING",
            "director": "Mr.Luo"
        }
    ]
}

上述一般都是对于最外层字段值的更新,在实际工作中通常会有对子级、或者子级的子级(嵌套)值做更新的情况,目前已知至少有两种方式:

  1. 原生数据库语句操作

    • 内嵌-增

      Query query = Query.query(Criteria.where("_id").is("962011536"));
      //需要做新增的值
      Projects pro = new Projects();
      pro.setName("财务系统");
      pro.setRole("后端开发");
      pro.setDirector("Mr.Liu");
      pro.setProjectStatus(ProjectStatusEnum.PROGRESSING);
      //projects 是字段名,把 pro 添加到 projects 的集合中内嵌元素
      Update update = new Update().push("projects", pro);
      this.mongoTemplate.updateFirst(query, update, Worker.class);
      
    • 内嵌-改

      Query query = Query.query(Criteria.where("_id").is("962011536"));
      //修改条件:明确到修改 projects 数组中的哪些数据,不声明的话则在全 projects 数组中遍历每一个元素去匹配
      query.addCriteria(Criteria.where("projects.name").is("日志系统"));
      //$ 符号表示连接父级和子级关系,修改多条数据则把 $ 改成 $[]
      Update update = new Update().set("projects.$.role","Java开发");
      this.mongoTemplate.updateFirst(query, update, Worker.class);
      
    • 内嵌-删

      Query query = Query.query(Criteria.where("_id").is("962011536"));
      Projects pro = new Projects();
      pro.setName("OA系统");
      //projects 是字段名,根据 pro 中条件查找匹配的 projects 对象进行删除
      Update update = new Update().pull("projects",pro);
      this.mongoTemplate.updateFirst(query, update, Worker.class);
      

    注:大家可能也发现了,这里的嵌套数据结构是比较简单的示例,最多只有一个 list 嵌套,更复杂的 json 和 list 嵌套之后我会写另外一篇文章单独分析。

  2. Java 语法操作

    使用 Java 语言来操作的话,本质上就是对实体映射字段(也就是需要更新的字段)进行赋值,最后再对整个实体进行 save() ;

    即:我虽然只需要更新某一个或者几个字段的值,但最后会更新整个实体(文档里所有字段);

    本质上是因为 Spring Data MongoDB 对实体映射做了很好地匹配,几乎所有常见的 Java 数据结构映射都可以支持。

    例:现在需要更新该员工所参与项目中的角色

        /**
         * Java 语言更新操作:现在需要更新该员工所参与项目中的角色
         * @param someDTO
         */
        public Boolean updateAndJava(SomeDTO someDTO){
            // 其实在最开始的地方可以利用DTO与实体对象相互转换,让实体内的属性都带上DTO的属性值,因为最后save的是实体对象
            Worker worker = this.mongoTemplate.findById(someDTO.getId(), Worker.class);
            if (Objects.nonNull(worker)){
                worker.getProjects().forEach(projects -> {
                    if (("OA系统").equals(projects.getName())){
                        projects.setRole(someDTO.getRole());
                    }
                });
            }
            // 这里用的是 MongoRepository 的save(),去更新整个文档
            Assert.notNull(this.mongoRepository.save(worker), "更新失败!");
            return Boolean.TRUE;
        }
    

4.4查 · find

注:这里只讨论 find 相关的使用,但是一般使用 find 查询前都需要使用条件,简单 or 复杂条件的构造,请查阅本文的2.2和2.3小节。

主要包括对 find()、findById()、findAllById()、findOne() 、findAll()、findDistinct()等的使用,具体如下图所示:

find 相关查询

4.5统计 · count

目前已知至少 3 种计数(统计所有条数或者按条件计数)的用法:

  • 按条件统计记录条数:

    Query query = new Query(Criteria
                    .where(fn.fnToFieldName(Worker::getStatus)).is(Boolean.TRUE));
    long count = this.mongoTemplate.count(query, Worker.class);
    
    Worker worker = new Worker();
    worker.setName("Alex");
    Example<Worker> workerExample = Example.of(worker);
    long count = this.mongoRepository.count(workerExample);
    
  • 无条件统计全部记录条数

    long count = this.mongoRepository.count();
    

4.6排序 · sort

目前已知至少有 2 种排序的写法:

  1. 直接在 query 条件后使用 with 关键字,再使用Sort.by(字段).按序

    Query query = new Query();
    query.with(Sort.by(fn.fnToFieldName(Worker::getId)).descending());
    
  2. 直接在 query 条件后使用 with 关键字,再使用Sort.by(按序, 字段);

    Query query = new Query();
    query.with(Sort.by(Sort.Direction.ASC, fn.fnToFieldName(Worker::getId)));
    

4.7分页查询返回 · page

目前已知至少有2种分页查询返回的用法:

  1. 不带转换器

        public Page<Worker> findAllPage(Integer PageNum, Integer PageSize) {
            Query query = new Query(Criteria
                    .where(fn.fnToFieldName(Worker::getStatus)).is(Boolean.TRUE));
            Pageable pageable = PageRequest.of(PageNum, PageSize);
            long count = this.mongoTemplate.count(query, Worker.class);
            List<T> list = this.mongoTemplate.find(query.with(pageable), Worker.class);
            return PageableExecutionUtils.getPage(list, pageable, () -> count);
        }
    
  2. 带转换器(返回VO)

        /**
         * 所谓转换器,即VO和实体之间的转换,一般不会返回整个实体page
         */
        public Page<WorkerVO> findAllPage(Query query, Pageable pageable, Function<? super T, ? extends U> converter) {
                long count = this.mongoTemplate.count(query, Worker.class);
                List<T> list = this.mongoTemplate.find(query.with(pageable), Worker.class);
                return PageableExecutionUtils.getPage(list, pageable, () -> count).map(converter);
            }
    

五、小结

从接触 MongoDB 到现在3个多月,总的学习分享下来,个人觉得MongoDB不愧是号称“非关系数据库中最像关系型的”,虽然是 NoSQL 但是其实还是有很多自己的操作语句可以写的,和 Redis 还是很有区别的。

最关键的还是要搞清楚MongoDB的集合结构,Java 内的实体都是按照这个对应的,同时还有一些比较进阶的如聚合、关联、事务和索引等,我有时间会再学习分享的。

最后,如有错误或者不足,还望大家指正,不吝赐教。

六、参考文献

  1. Spring Data MongoDB官方文档:

    https://docs.spring.io/spring-data/mongodb/docs/2.0.14.RELEASE/reference/html/#mongo.query

  2. MongoDB 中文手册:

    https://docs.mongoing.com/mongodb-crud-operations/query-documents