PAYATHON 2026

如何将 MongoDB aggregation 转为 Spring WebFlux 代码

支付小周

结论

问题在于 $replaceRoot 的构造方式。ObjectOperators.valueOf("payment").mergeWith(Aggregation.ROOT) 执行的是对象合并,无法构造出下面的新文档:

{
  "_id": "$_id",
  "type": "payment",
  "payment": "$$ROOT"
}

应改用 replaceRoot().withDocument() 明确定义新的根文档,并分别处理字段引用、字符串常量和 $$ROOT。$unionWith 中的 pipeline 也需要使用相同的写法。

正确的聚合代码

import static org.springframework.data.mongodb.core.aggregation.Aggregation.limit;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.replaceRoot;

import java.util.List;

import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
import org.springframework.data.mongodb.core.aggregation.LimitOperation;
import org.springframework.data.mongodb.core.aggregation.ReplaceRootOperation;
import org.springframework.data.mongodb.core.aggregation.UnionWithOperation;

import reactor.core.publisher.Flux;

ReplaceRootOperation replacePayment = replaceRoot()
        .withDocument()
        .andValueOf("_id").as("_id")
        .andValue("payment").as("type")
        .andValueOf(Aggregation.ROOT).as("payment");

ReplaceRootOperation replaceRefund = replaceRoot()
        .withDocument()
        .andValueOf("_id").as("_id")
        .andValue("refund").as("type")
        .andValueOf(Aggregation.ROOT).as("refund");

UnionWithOperation unionWithRefund = UnionWithOperation
        .unionWith("refund")
        .pipeline(replaceRefund);

LimitOperation limitOperation = limit(pageSize);

List<AggregationOperation> operations = List.of(
        replacePayment,
        unionWithRefund,
        limitOperation
);

Aggregation aggregation = newAggregation(operations);

Flux<Transaction> output = mongoTemplate.aggregate(
        aggregation,
        "payment",
        Transaction.class
);

生成的核心 pipeline 应与下面的结构等价:

[
  {
    $replaceRoot: {
      newRoot: {
        _id: "$_id",
        type: "payment",
        payment: "$$ROOT"
      }
    }
  },
  {
    $unionWith: {
      coll: "refund",
      pipeline: [
        {
          $replaceRoot: {
            newRoot: {
              _id: "$_id",
              type: "refund",
              refund: "$$ROOT"
            }
          }
        }
      ]
    }
  },
  {
    $limit: pageSize
  }
]

为什么原来的写法无法得到预期结果

这段代码:

ObjectOperators.valueOf("payment").mergeWith(Aggregation.ROOT)

表示对象合并操作。其中,"payment" 会作为字段引用参与表达式计算,而不是创建一个名为 payment 的字段。mergeWith() 也不会自动添加 type、payment 或 refund 等包装字段。

因此,聚合结果仍接近原始的 payment 文档。MongoDB 能够返回 _id,所以 Transaction.id 有值;但结果中没有顶层的 type、payment 和 refund,Spring Data 映射后,这些属性就会是 null。

使用 withDocument(),相当于明确告诉 Spring Data,新根节点是一个重新构造的文档。

下面三种写法的含义并不相同:

.andValueOf("_id").as("_id")                 // 引用当前文档的 $_id
.andValue("payment").as("type")              // 字符串常量 "payment"
.andValueOf(Aggregation.ROOT).as("payment")  // 引用整个当前文档 $$ROOT

当 Spring Data 版本的 Builder 行为不一致时

如果当前 Spring Data MongoDB 版本不支持上述 withDocument() 链式 API,或者 $$ROOT 没有被正确渲染,可以直接构造 BSON stage。这种方式更贴近原始 MongoDB script,排查问题也更直接。

import org.bson.Document;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
import org.springframework.data.mongodb.core.aggregation.UnionWithOperation;

AggregationOperation replacePayment = context ->
        new Document("$replaceRoot",
                new Document("newRoot",
                        new Document("_id", "$_id")
                                .append("type", "payment")
                                .append("payment", "$$ROOT")));

AggregationOperation replaceRefund = context ->
        new Document("$replaceRoot",
                new Document("newRoot",
                        new Document("_id", "$_id")
                                .append("type", "refund")
                                .append("refund", "$$ROOT")));

UnionWithOperation unionWithRefund = UnionWithOperation
        .unionWith("refund")
        .pipeline(replaceRefund);

Aggregation aggregation = Aggregation.newAggregation(
        replacePayment,
        unionWithRefund,
        Aggregation.limit(pageSize)
);

Flux<Transaction> output = mongoTemplate.aggregate(
        aggregation,
        "payment",
        Transaction.class
);

Transaction 映射结构

Transaction 的属性名称和类型必须与聚合输出保持一致,例如:

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

@Document("transaction")
public class Transaction {

    @Id
    private String id;

    private String type;

    private Payment payment;

    private Refund refund;

    // getters and setters
}

如果 MongoDB 中的 _id 不是字符串,而是 ObjectId、UUID 或其他类型,id 应改为对应的 Java 类型。嵌套的 Payment 和 Refund 类也需要能够映射原 collection 中的字段。

每条结果只会填充一个业务字段:

type = payment  -> payment 有值,refund 为 null
type = refund   -> refund 有值,payment 为 null

这符合当前聚合结构的预期。

WebFlux 中不要调用 blockFirst()

下面的代码可以用于临时测试:

Transaction transaction = output.blockFirst();

但不要在 WebFlux 请求处理线程中执行阻塞操作,应直接返回响应式类型:

public Flux<Transaction> findTransactions(int pageSize) {
    return mongoTemplate.aggregate(
            aggregation,
            "payment",
            Transaction.class
    );
}

如果只需要第一条记录,可以返回:

public Mono<Transaction> findFirstTransaction(int pageSize) {
    return mongoTemplate.aggregate(
            aggregation,
            "payment",
            Transaction.class
    ).next();
}

是否会创建 transaction collection

aggregate(..., Transaction.class) 中的 Transaction.class 只用于结果映射,不会创建或写入名为 transaction 的 collection。当前 pipeline 只是合并 payment 和 refund,再以 Transaction 结构返回结果。

如果需要将结果持久化到 transaction collection,还要在 pipeline 末尾添加 $merge 或 $out。这会产生数据库写操作,因此需要另外处理重复 _id、覆盖策略和执行权限。

备注:内容仅供参考。