Java Reactor 中嵌套 flatMap 的好习语是什么?

8

我接手了一个使用Spring和相关库,包括Reactor编写的Java REST服务。对于像REST调用或数据库操作这样的昂贵操作,代码在很大程度上将结果封装到Reactor Mono中。

代码中需要处理各种问题,但其中一个问题是嵌套MonoflatMap,用于执行一系列昂贵的操作,导致缩进几个级别,变成难以阅读的混乱。我发现这特别令人恼火,因为我之前从Scala过来,那里使用flatMap的方式不会像这样糟糕,因为for推导式语法糖可以使所有内容保持大致相同的作用域而不会变得更深。

目前为止,我还没有找到一种方法来解决这个问题,使其更易读,除了进行大规模重构(即使在这种情况下,我也不确定从何处开始进行这种重构)。

下面是基于代码的匿名示例,(所有语法错误都是匿名化引起的):

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;
}

1个回答

7

呃,这段代码有几个问题我不喜欢,但首先说一下最大的问题 - 嵌套。

嵌套的唯一原因是,在(例如)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模式生成具有更新字段的新实例d2。 然后您可以调用expensiveOp5(d1, d2.withStatus(Status.ACTIVE), d3)丢弃该setter调用。

1
谢谢你提供这些好的惯用语。下次我重构这种模式时,我一定会应用它们。在使用Java惯用语方面,你认为在回答中定义一个特定类(如IntermediateResult1)与使用Reactor提供的通用Tuple类相比,哪个更好呢? - Phyzz
没问题。Java中的响应式编程还比较新,因此在所有情况下都尚未普遍确定惯用语和最佳实践。我猜随着时间的推移,这种情况将会改变,更多这类模式将变得更加普遍。我几乎总是使用命名的中间类而不是通用元组,定义“处理阶段”并按照这些阶段进行工作。例如,在类似的应用程序中,我有PreProcessedRecordProcessedRecordAuditedRecord作为类,以及preProcessprocessaudit作为方法名称。至于适合的命名,因人而异! - Michael Berry
在您无法控制嵌套发布者的情况下,您通常会怎么做?例如:Mono ultVal = Mono.create(callback -> { myCallback.apply(someData).flatMap(d -> req.getBodyAsString().flatMap(d1 -> { callback.success(resp); return Mono.just(d); })); 我试图将回调链接到事件循环中,并且无法控制“req.getBodyAsString()”。 - Jeremy Reed

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接