关于 javascript:RxJS mergeMap() with original order

RxJS mergeMap() with original order

抽象的问题

有什么方法可以按照外部 observable 的原始顺序消耗 mergeMap 的结果,同时仍然允许内部 observable 并行运行?

更详细的解释

让我们看看两个合并映射运算符:

  • mergeMap

    ...它接受一个映射回调,以及可以同时运行多少个内部可观察对象:

    1
    2
    3
      of(1, 2, 3, 4, 5, 6).pipe(
          mergeMap(number => api.get('/double', { number }), 3)
      );

    在此处查看实际操作:https://codepen.io/JosephSilber/pen/YzwVYNb?editors=1010

    这将分别触发 123 的 3 个并行请求。一旦其中一个请求完成,它将触发另一个对 4 的请求。以此类推,始终保持 3 个并发请求,直到处理完所有值。

    但是,由于先前的请求可能在后续请求之前完成,因此生成的值可能会乱序。所以代替:

    1
      [2, 4, 6, 8, 10, 12]

    ...我们实际上可能得??到:

    1
      [4, 2, 8, 10, 6, 12] // or any other permutation
  • concatMap

    ...输入 concatMap。此运算符确保所有可观察对象都按原始顺序连接,因此:

    1
    2
    3
      of(1, 2, 3, 4, 5, 6).pipe(
          concatMap(number => api.get('/double', { number }))
      );

    ...总是会产生:

    1
      [2, 4, 6, 8, 10, 12]

    在此处查看实际操作:https://codepen.io/JosephSilber/pen/OJMmzpy?editors=1010

    这是我们想要的,但现在请求不会并行运行。正如文档所说:

    concatMap is equivalent to mergeMap with concurrency parameter set to 1.

所以回到问题:是否有可能获得 mergeMap 的好处,即可以并行运行给定数量的请求,同时仍然以原始顺序发出映射值?

我的具体问题

上面对这个问题进行了抽象的描述。当您知道手头的实际问题时,有时会更容易推断问题,所以这里是:

  • 我有一份需要发货的订单清单:

    1
     const orderNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
  • 我有一个实际发送订单的 shipOrder 方法。它返回一个 Promise:

    1
     const shipOrder = orderNumber => api.shipOrder(orderNumber);
  • API 最多只能同时处理 5 个订单发货,所以我使用 mergeMap 来处理:

    1
    2
    3
     from(orderNumbers).pipe(
         mergeMap(orderNumber => shipOrder(orderNumber), 5)
     );
  • 订单发货后,我们需要打印其发货标签。我有一个 printShippingLabel 函数,给定已发货订单的订单号,它将打印其运输标签。所以我订阅了我们的 observable,并在输入值时打印运输标签:

    1
    2
    3
     from(orderNumbers)
         .pipe(mergeMap(orderNumber => shipOrder(orderNumber), 5))
         .pipe(orderNumber => printShippingLabel(orderNumber));
  • 这可行,但现在运输标签打印乱序,因为 mergeMap 根据 shipOrder 完成其请求的时间发出值。我想要的是标签以与原始列表相同的顺序打印。

  • 这可能吗?

    可视化

    有关问题的可视化,请参见此处:https://codepen.io/JosephSilber/pen/YzwVYZb?editors=1010

    您可以看到,较早的订单在发货之前就已打印出来。


    我确实设法部分解决了它,所以我将它发布在这里作为我自己问题的答案。

    我还是很想知道处理这种情况的规范方法。

    一个复杂的解决方案

  • 创建一个自定义运算符,该运算符接受具有索引键的值(Typescript 用语中的 { index: number }),并保留值的缓冲区,仅根据它们 index 的顺序发出它们。

  • 将原始列表映射为嵌入了 index 的对象列表。

  • 将其传递给我们的自定义 sortByIndex 运算符。

  • 将这些值映射回它们的原始值。

  • 这就是 sortByIndex 的样子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    function sortByIndex() {
        return observable => {
            return Observable.create(subscriber => {
                const buffer = new Map();
                let current = 0;
                return observable.subscribe({
                    next: value => {
                        if (current != value.index) {
                            buffer.set(value.index, value);
                        } else {
                            subscriber.next(value);
                       
                            while (buffer.has(++current)) {
                                subscriber.next(buffer.get(current));
                                buffer.delete(current);
                            }
                        }
                    },
                    complete: value => subscriber.complete(),
                });
            });
        };
    }

    使用 sortByIndex 运算符,我们现在可以完成整个管道:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    of(1, 2, 3, 4, 5, 6).pipe(
        map((number, index) => ({ number, index })),
        mergeMap(async ({ number, index }) => {
            const doubled = await api.get('/double', { number });
            return { index, number: doubled };
        }, 3),
        sortByIndex(),
        map(({ number }) => number)
    );

    在此处查看实际操作:https://codepen.io/JosephSilber/pen/zYrwpNj?editors=1010

    创建 concurrentConcat 运算符

    事实上,有了这个 sortByIndex 操作符,我们现在可以创建一个通用的 concurrentConcat 操作符,它将在内部进行与 { index: number, value: T } 类型的转换:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function concurrentConcat(mapper, parallel) {
        return observable => {
            return observable.pipe(
                mergeMap(
                    mapper,
                    (_, value, index) => ({ value, index }),
                    parallel
                ),
                sortByIndex(),
                map(({ value }) => value)
            );
        };
    }

    然后我们可以使用这个 concurrentConcat 操作符而不是 mergeMap,它现在会按照原来的顺序发出值:

    1
    2
    3
    of(1, 2, 3, 4, 5, 6).pipe(
        concurrentConcat(number => api.get('/double', { number }), 3),
    );

    在此处查看实际操作:https://codepen.io/JosephSilber/pen/pogPpRP?editors=1010

    所以要解决我原来的订单发货问题:

    1
    2
    3
    from(orderNumbers)
        .pipe(concurrentConcat(orderNumber => shipOrder(orderNumber), maxConcurrent))
        .subscribe(orderNumber => printShippingLabel(orderNumber));

    在此处查看实际操作:https://codepen.io/JosephSilber/pen/rNxmpWp?editors=1010

    您可以看到,即使后来的订单可能会在较早的订单之前发货,但标签始终按原始顺序打印。

    结论

    这个解决方案甚至不完整(因为它不处理发出多个值的内部可观察对象),但它需要一堆自定义代码。这是一个常见的问题,我觉得必须有一种更简单(内置)的方法来解决这个问题:|


    你可以使用这个操作符:sortedMergeMap, example.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    const DONE = Symbol("DONE");
    const DONE$ = of(DONE);
    const sortedMergeMap = <I, O>(
      mapper: (i: I) => ObservableInput<O>,
      concurrent = 1
    ) => (source$: Observable) =>
      source$.pipe(
        mergeMap(
          (value, idx) =>
            concat(mapper(value), DONE$).pipe(map(x => [x, idx] as const)),
          concurrent
        ),
        scan(
          (acc, [value, idx]) => {
            if (idx === acc.currentIdx) {
              if (value === DONE) {
                let currentIdx = idx;
                const valuesToEmit = [];
                do {
                  currentIdx++;
                  const nextValues = acc.buffer.get(currentIdx);
                  if (!nextValues) {
                    break;
                  }
                  valuesToEmit.push(...nextValues);
                  acc.buffer.delete(currentIdx);
                } while (valuesToEmit[valuesToEmit.length - 1] === DONE);
                return {
                  ...acc,
                  currentIdx,
                  valuesToEmit: valuesToEmit.filter(x => x !== DONE) as O[]
                };
              } else {
                return {
                  ...acc,
                  valuesToEmit: [value]
                };
              }
            } else {
              if (!acc.buffer.has(idx)) {
                acc.buffer.set(idx, []);
              }
              acc.buffer.get(idx)!.push(value);
              if (acc.valuesToEmit.length > 0) {
                acc.valuesToEmit = [];
              }
              return acc;
            }
          },
          {
            currentIdx: 0,
            valuesToEmit: [] as O[],
            buffer: new Map<number, (O | typeof DONE)[]>([[0, []]])
          }
        ),
        mergeMap(scannedValues => scannedValues.valuesToEmit)
      );

    你想要的是这样的:

    1
    2
    3
    from(orderNumbers)
      .pipe(map(shipOrder), concatAll())
      .subscribe(printShippingLabel)

    解释:

    管道中的第一个运算符是map。它立即为每个值调用 shipOrder(因此后续值可能会启动并行请求)。

    第二个运算符 concatAll 将解析后的值按正确的顺序排列。

    (我简化了代码;concatAll() 等价于 concatMap(identity)。)