学习Spring Data MongoDB模块中的Aggregation

1,149 阅读7分钟

简介

MongoDB是一个基于文档的NoSQL数据库,它将数据以 ***BSON(二进制JSON)***格式存储数据。

与任何数据库一样,你会经常调用读取、写入或更新存储在文档存储中的数据。在许多情况下,检索数据并不像写一个查询那么简单(尽管查询可以变得相当复杂)。

使用MongoDB -- 聚合是用来处理许多文档,并返回一些计算结果的。这可以通过创建一个 管线的操作来实现的,其中每个操作都接收一组文档,并根据一些标准对其进行过滤。

Spring Data MongoDB是Spring的模块,作为Spring Boot应用程序和MongoDB之间的接口。当然,它提供了一组注解,使我们能够轻松地 "切换 "功能,并让模块本身知道它何时应该为我们处理事情。

@Aggregation 注解用于注解 Spring Boot Repository 方法,并调用你提供给@Aggregation 注解的pipeline() 操作。

在本指南中,我们将看看如何利用@Aggregation 注解来聚合 MongoDB 数据库中的结果,什么是聚合管道,如何使用命名和位置方法参数进行动态聚合,以及如何对结果进行排序和分页。

领域模型和存储库

让我们从我们的领域模型和一个简单的资源库开始。我们将创建一个Property ,作为一个具有几个相关字段的房地产的模型。

@Document(collection = "property")
public class Property {

    @Id
    private String id;
    @Field("price")
    private int price;
    @Field("area")
    private int area;
    @Field("property_type")
    private String propertyType;
    @Field("transaction_type")
    private String transactionType;
    
    // Constructor, getters, setters, toString()
    
}

还有一个简单的相关的MongoRepository

@Repository
public interface PropertyRepository extends MongoRepository<Property, String> {}

提醒: MongoRepository 是一个PagingAndSortingRepository ,它最终是一个CrudRepository

通过聚合,你自然也可以对结果进行排序和分页,利用了它对Spring Data的PagingAndSortingRepository 接口的扩展。

了解@Aggregation注解

@Aggregation 注解是在方法层面上应用的,在一个@Repository 。该注解接受一个pipeline --一个字符串数组,其中每个字符串代表管道中的一个阶段(要运行的操作)。每一个下一个阶段都是对前一个阶段的结果进行操作。

存在各种阶段,它们允许你执行各种各样的操作,一些比较常用的是:

  • $match - 根据文档的字段是否与一个给定的谓词相匹配来过滤文档。
  • $count - 返回管道中剩余文档的数量。
  • $limit - 限制返回文档的(切片)数量,从集合的开头开始,接近极限。
  • $sample - 从一个集合中随机抽取一定数量的文档。
  • $sort - 对给定字段和排序顺序的文档进行排序。
  • $merge - 将流水线上的文档写入一个集合中。

其中一些是终端操作(在最后应用),比如$merge 。排序也必须在其余的过滤工作已经完成之后进行。

在我们的例子中,要在我们的资源库中添加一个@Aggregation ,我们只需要添加一个方法并对其进行注释。

@Aggregation(pipeline = {
        "Operation/Stage 1...",
        "Operation/Stage 2...",
        "Operation/Stage 3...",
})
List<Property> someMethod();

或者,你可以让它们保持内联

@Aggregation(pipeline = {"Operation/Stage 1...", "Operation/Stage 2...", "Operation/Stage 3..."})
List<Property> someMethod();

根据你所拥有的阶段的数量,后一种选择可能会很快变得难以辨认。一般来说,将阶段分解成新的行,有助于提高可读性。

既然如此,让我们在管道中添加一些操作吧例如,让我们搜索有一个与给定值相匹配的字段的属性,例如transactionType 等于"For Sale" 的属性。

@Aggregation(pipeline = {
    "{'$match':{'transaction_type':'For Sale'}",
})
List<Property> findPropertiesForSale();

虽然,有这样一个单一的匹配,胜过了聚合的意义。让我们添加一些更多的匹配条件。不要忘记,你可以在这里提供任何数量的匹配条件,包括选择器/操作器,如$gt ,以进一步过滤。

@Aggregation(pipeline = {
    "{'$match':{'transaction_type':'For Sale', 'price' : {$gt : 100000}}",
})
List<Property> findExpensivePropertiesForSale();

现在,我们要搜索与transaction_type 匹配的属性,但有一个price 大于($gt)100000!即使有两个这样的条件,仅仅有一个$match 阶段也不一定要有一个@Aggregation ,尽管它仍然是一个完全有效的方法,可以根据多个条件获得结果。

此外,当你处理固定值的时候,这就不好玩了。谁能说这是一个昂贵的属性?如果能够为方法调用提供一个较低的标记,并使用这个标记来代替$gt 操作符,那就更有用了。

这就是命名的和定位的方法参数的用处。

引用命名和定位的方法参数

我们很少处理静态数字,因为它们并不灵活。我们希望为终端用户提供灵活性,为开发者提供灵活性。在前面的例子中,我们使用了两个固定值--'For Sale' ,和100000 。在这一节中,我们将用命名和位置的方法参数来代替这两个参数,并通过方法的参数来提供它们

使用命名参数或位置参数并不改变代码的功能,通常由工程师/团队根据他们的喜好来决定采用哪种方案。一旦你选择了一种类型,就应该保持一致。

@Aggregation(pipeline = {
        "{'$match':{'transaction_type': ?0, 'price' : {$gt : ?1}}",
})
List<Property> findPropertiesByTransactionTypeAndPriceGTPositional(String transactionType, int price);

@Aggregation(pipeline = {
        "{'$match':{'transaction_type': #{#transactionType}, 'price' : {$gt : #{#price}}}",
})
List<Property> findPropertiesByTransactionTypeAndPriceGTNamed(@Param("transactionType") String transactionType, @Param("price") int price);

前者更简明,但它确实需要你强制执行参数的输入顺序。此外,如果数据库中的字段本身没有指示类型/期望值(这是不好的设计,但有时是你无法控制的)--使用位置参数可能会增加混乱,因为在你可以期望什么值方面有一点模糊。

不可否认,后者更加啰嗦,但它确实允许你混合参数的顺序。没有必要强制它们的位置,因为它们被@Param 注释与SpEL表达式相匹配,将它们与操作管道中的名称相联系。

这里没有客观上更好的选择,也没有被业界广泛接受的选择。选择你自己觉得更舒服的那个。

**提示:**如果你已经打开了DEBUG 作为你的日志级别--你将能够在日志中看到发送到Mongo的查询。你可以将该查询复制粘贴到MongoDB Atlas中,以检查该查询是否在那里返回正确的结果,并验证你是否不小心弄乱了位置。有可能的是--你的查询没有问题,但你只是混淆了位置,所以结果是空的。

现在,你可以向方法调用提供数值,它们将被动态地用于@Aggregation 。这允许你在各种调用中重复使用相同的方法,例如,获取活动属性。这将是一个常见的调用,所以无论你检索5个、10个还是100个,你都可以重复使用同一个方法。

当你处理较大的数据体时,也值得研究一下排序和分页。最终用户不应该被期望自己对数据进行分类。

排序和分页

排序通常是在最后进行的,因为事先的排序最终可能是多余的。你可以在聚合后应用排序方法,也可以在聚合过程中应用。

在这里,我们将探索在聚合本身中应用排序方法的前景。我们将对一些属性进行采样,并对它们进行排序,比如,通过area 。这可以是任何其他的字段,比如说price,datePublished,sponsored, 等等。$sort 操作接受一个要排序的字段,以及顺序(其中1ascending-1 是降序)。

@Aggregation(pipeline = {
        "{'$match':{'transaction_type':?0, 'price': {$gt: ?1} }}",
        "{'$sample':{size:?2}}",
        "{'$sort':{'area':-1}}"
})
List<Property> findPropertiesByTransactionTypeAndPriceGT(String transactionType, int price, int sampleSize);

在这里,我们按面积对房产进行排序,以降序排列--这意味着,面积最大的房产将在排序中首先显示出来。交易类型、价格和样本大小都是可变的,可以动态地设置。

如果你想把分页功能纳入其中,可以采用标准的Spring Boot分页方法--你只需在方法定义和调用中添加一个Pageable pageable

@Aggregation(pipeline = {
        "{'$match':{'transaction_type':?0, 'price': {$gt: ?1} }}",
        "{'$sample':{size:?2}}",
        "{'$sort':{'area':-1}}"
})
Iterable<Property> findPropertiesByTransactionTypeAndPriceGTPageable(String transactionType, int price, int sampleSize, Pageable pageable);

当从控制器调用该方法时,你要构建一个Pageable 对象传入。

int page = 1;
int size = 5;

Pageable pageable = new PageRequest.of(page, size);
Page<Property> = propertyRepository.findPropertiesByTransactionTypeAndPriceGTPageable("For Sale", 100000, 5, pageable);

Page 将是第二页(索引1),5 的结果。

**注意:**由于我们已经在聚合中对属性进行了排序,所以没有必要在这里加入任何额外的排序配置。另外,你可以跳过聚合中的$sort ,而通过Pageable 实例进行排序。

创建一个REST API

让我们快速创建一个REST API,将这些方法的结果暴露给终端用户,并发送一个curl 请求来验证结果,从带有自动连接存储库的控制器开始。

@RestController
public class HomeController {
    @Autowired
    private PropertyRepository propertyRepository;
    
}

我们首先要给数据库添加一些属性。

@GetMapping("/addProperties")
public ResponseEntity addProperies() {

    List<Property> propertyList = List.of(
            new Property(100000, 45, "Apartment", "For Sale"),
            new Property(65000, 48, "Apartment", "For Sale"),
            new Property(280000, 75, "Apartment", "For Sale"),
            new Property(452000, 110, "House", "For Sale"),
            new Property(400000, 125, "House", "For Rent"),
            new Property(125000, 100, "Apartment", "For Sale"),
            new Property(95000, 70, "House", "For Rent"),
            new Property(35000, 25, "Apartment", "For Sale")
    );

    for (Property property : propertyList) {
        propertyRepository.save(property);
    }

    return ResponseEntity.ok().body(propertyList);
}

现在,让我们curl ,向这个端点发出请求,将属性添加到数据库中。

$ curl localhost:8080/addProperties

[ {
  "id" : "61dedea6799b5758bb857292",
  "price" : 100000,
  "area" : 45,
  "propertyType" : "Apartment",
  "transactionType" : "For Sale"
}, {
  "id" : "61dedea6799b5758bb857293",
  "price" : 65000,
  "area" : 48,
  "propertyType" : "Apartment",
  "transactionType" : "For Sale"
},
...

**注意:**为了得到一个漂亮的打印响应,记得在你的application.properties 中把Jackson的INDENT_OUTPUT 转为true

现在,让我们定义一个/getProperties 端点,它调用一个PropertyRepository 方法,执行一个聚合。

@GetMapping("/getProperties")
public ResponseEntity home() {
    return ResponseEntity
    .ok()
    .body(propertyRepository.findPropertiesByTransactionTypeAndPriceGT("For Sale", 100000, 5));
}

这应该从一组价格超过10万的待售房产中随机选择5个房产,并按其区域排序。如果没有5个样本可供选择--所有符合要求的房产都被返回。

$ curl localhost:8080/getProperties

[ {
  "id" : "61dedea6799b5758bb857295",
  "price" : 452000,
  "area" : 110,
  "propertyType" : "House",
  "transactionType" : "For Sale"
}, {
  "id" : "61dedea6799b5758bb857297",
  "price" : 125000,
  "area" : 100,
  "propertyType" : "Apartment",
  "transactionType" : "For Sale"
}, {
  "id" : "61dedea6799b5758bb857294",
  "price" : 280000,
  "area" : 75,
  "propertyType" : "Apartment",
  "transactionType" : "For Sale"
} ]

运作得很好!

总结

在本指南中,我们已经了解了Spring Data MongoDB模块中的@Aggregation 注解。我们介绍了什么是聚合,什么时候可以使用聚合,以及聚合与常规查询有何不同。

我们概述了聚合管道中的一些常见操作,然后用静态和动态参数编写我们自己的管道。我们探讨了聚合的位置参数和命名参数,最后,开发了一个简单的REST API来提供结果。