Java Reactor 中嵌套 flatMap 的好习惯用法是什么?

What is a good idiom for nested flatMaps in Java Reactor?

我继承了使用 Spring 和相关库(包括 Reactor)以 Java 编写的 REST 服务的职责。对于 REST 调出或数据库操作等昂贵的操作,代码将结果广泛包装在 Reactor Mono 中。

代码中有各种各样的事情需要解决,但不断出现的事情是嵌套 flatMaps over Monos 用于最终缩进的昂贵操作序列几个层次深入到一个不可读的混乱中。我发现它特别令人讨厌,因为我来自 Scala,在 Scala 中,这种使用 flatMap 的方式并没有那么糟糕,因为 for comprehension 语法糖将所有内容保持在大致相同的范围级别而不是更深入。

到目前为止,除了大规模重构之外,我还没有成功找到一种方法来解决这个问题以使其更具可读性(即便如此,我也不确定从哪里开始这样的重构)。

基于代码的匿名示例,(所有语法错误均来自匿名):

public Mono<OutputData> userActivation(InputData input) {
    Mono<DataType1> d1 = service.expensiveOp1(input);

    Mono<OutputData> result =
        d1
          .flatMap(
            d1 -> {
              return service
                  .expensiveOp2(d1.foo())
                  .flatMap(
                      d2 -> {
                        if (Status.ACTIVE.equals(d2.getStatus())) {
                          throw new ConflictException("Already active");
                        }

                        return service
                            .expensiveOp3(d1.bar(), d2.baz())
                            .flatMap(
                                d3 -> {
                                  d2.setStatus(Status.ACTIVE);

                                  return service
                                      .expensiveOp5(d1, d2, d3)
                                      .flatMap(
                                          d4 -> {
                                            return service.expensiveOp6(d1, d4.foobar())
                                          });
                                });
                      });
            })

    return result;
}

哎呀。我不喜欢该片段的一些地方,但我将从大的地方开始 - 嵌套。

嵌套的唯一原因是,在(例如)expensiveOp5() 中,您需要引用 d1d2d3,而不仅仅是 d4 - 所以你不能只是“正常”映射,因为你失去了那些早期的参考。有时可以在特定上下文中重构这些依赖项,因此我会先检查该路径。

但是,如果这不可能或不可取,我倾向于发现像这样的深度嵌套 flatMap() 调用最好通过组合替换为中间对象。

如果你有一堆 类 例如:

@Data
class IntermediateResult1 {
    private DataType1 d1;
    private DataType2 d2;
}

@Data
class IntermediateResult2 {
    public IntermediateResult2(IntermediateResult1 i1, DataType3 d3) {
        this.d1 = i1.getD1();
        this.d2 = i1.getD2();
        this.d3 = d3;
    }
    private DataType1 d1;
    private DataType2 d2;
    private DataType3 d3;
}

...等等,然后你可以做这样的事情:

    return d1.flatMap(d1 -> service.expensiveOp2(d1.foo()).map(d2 -> new IntermediateResult1(d1, d2)))
             .flatMap(i1 -> service.expensiveOp3(i1).map(s3 -> new IntermediateResult2(i1, d3)))
             //etc.

当然,您也可以将调用分解为它们自己的方法以使其更清晰(在这种情况下我可能会建议这样做):

return d1.flatMap(this::doOp1)
         .flatMap(this::doOp2)
         .flatMap(this::doOp3)
         .flatMap(this::doOp4)
         .flatMap(this::doOp5);

显然,我在上面使用的名称应该被视为占位符 - 您应该仔细考虑这些名称,因为这里的良好命名将使对反应流的推理和解释更加自然。

除了嵌套,代码中还有两点值得注意:

  • 使用 return Mono.error(new ConflictException("Already active")); 而不是显式抛出,因为它可以更清楚地表明您正在处理流中的显式 Mono.error
  • 从不 在反应链中途使用像 setStatus() 这样的可变方法 - 这会在以后提出问题。相反,使用 with pattern 之类的东西来生成具有更新字段的 d2 的新实例。然后您可以调用 expensiveOp5(d1, d2.withStatus(Status.ACTIVE), d3),同时放弃 setter 调用。