RxJS:批处理请求和共享响应

时间:2018-09-11 16:03:17

标签: javascript typescript rxjs rxjs6

假设我有一个函数fetchUser,它以参数userId作为参数并返回一个可观察到的用户

由于我经常调用此方法,因此我想 batch 个ID来代替多个ID来执行一个请求!

这是我的烦恼开始了...

在没有fetchUser的不同调用之间共享可观察对象的情况下,我找不到解决方案。

import { Subject, from } from "rxjs"
import { bufferTime, mergeMap, map, toArray, filter, take, share } from "rxjs/operators"

const functionThatSimulateAFetch = (userIds: string[]) => from(userIds).pipe(
    map((userId) => ({ id: userId, name: "George" })),
    toArray(),
)

const userToFetch$ = new Subject<string>()

const fetchedUser$ = userToFetch$.pipe(
    bufferTime(1000),
    mergeMap((userIds) => functionThatSimulateAFetch(userIds)),
    share(),
)

const fetchUser = (userId: string) => {
    const observable = fetchedUser$.pipe(
        map((users) => users.find((user) => user.id === userId)),
        filter((user) => !!user),
        take(1),
    )
    userToFetch$.next(userId)
    return observable
}

但这很丑,而且有很多麻烦:

  • 如果我在fetchUser的计时器结束之前取消订阅bufferTime返回的可观察对象,这不会阻止用户的获取。
  • 如果我在批量提取完成之前取消订阅fetchUser返回的所有可观察对象,那么它不会取消请求。
  • 错误处理更为复杂

更笼统地说:我不知道如何使用RxJS解决需要共享资源的问题。很难找到RxJS的高级示例。

4 个答案:

答案 0 :(得分:2)

我认为@Biggy是正确的。

这是我理解问题以及您要实现的目标的方式

  1. 您的应用中有多个要获取用户的地方
  2. 您不想一直触发查询请求,而是 要缓冲它们并在一定时间间隔内发送它们, 比方说1秒
  3. 您要取消某个缓冲区,并在那一秒钟内避免使用该缓冲区 间隔触发提取一批用户的请求
  4. 同时,如果有人,我们将其称为 位置代码 X 要求用户,仅几毫秒后有人 否则,即 位置Y上的代码 会取消整批 请求,则 位置X上的代码 必须收到某种 回答,比如说null
  5. 更多,您可能希望能够要求获取一个用户然后进行更改 您的想法,如果在缓冲时间间隔内,并避免 要获取此用户(我不确定这是否确实是 您想要,但是似乎可以从您的问题中找到答案

如果这都是真的,那么您可能必须像Buggy建议的那样具有某种排队机制。

那么可能有很多这种机制的实现。

答案 1 :(得分:2)

您拥有的东西很好,但是与所有RxJS一样,但细节在于魔鬼。

问题

  1. switchMap
        mergeMap((userIds) => functionThatSimulateAFetch(userIds)),

这是您第一次出错。通过在此处使用合并映射,您将无法从“单个请求返回的流”中告诉appart“请求流”:

  • 您几乎无法取消订阅单个请求(取消该请求)
  • 您将无法处理错误
  • 如果内部可观察到的物体发射不止一次,那将是一件好事。

相反,您想要的是通过正常的BatchEvent(产生可观察的可观察物)和map / switchMap发出单独的mergeMap那些过滤之后。

  1. 在订阅之前创建可观察和发射的副作用
    userToFetch$.next(userId)
    return observable

不要这样做。可观察者本身实际上不做任何事情。这是您在订阅时 进行一系列操作的“蓝图”。这样,您只能在可观察的创建上创建批处理操作,但是如果您获得了多个或延迟的订阅,就会很费力。

相反,您希望从defer创建一个可观察对象,并在每次订阅时向userToFetch$发出。

即使那样,您也要先订阅可观察的 ,然后再发送给userToFetch:如果您未订阅,则可观察的对象不会收听主题,并且该事件将迷路了。您可以以类似延缓的可观察方式进行操作。

解决方案

简短,与您的代码没有太大区别,但结构如下。

const BUFFER_TIME = 1000;

type BatchEvent = { keys: Set<string>, values: Observable<Users> };

/** The incoming keys */
const keySubject = new Subject<string>();

const requests: Observable<{ keys: Set<string>, values: Observable<Users> }> =
  this.keySubject.asObservable().pipe(
    bufferTime(BUFFER_TIME),
    map(keys => this.fetchBatch(keys)),
    share(),
  );

/** Returns a single User from an ID. Batches the request */
function get(userId: string): Observable<User> {
  console.log("Creating observable for:", userId);
  // The money observable. See "defer":
  // triggers a new subject event on subscription
  const observable = new Observable<BatchEvent>(observer => {
    this.requests.subscribe(observer);
    // Emit *after* the subscription
    this.keySubject.next(userId);
  });
  return observable.pipe(
    first(v => v.keys.has(userId)),
    // There is only 1 item, so any *Map will do here
    switchMap(v => v.values),
    map(v => v[userId]),
  );
}

function fetchBatch(args: string[]): BatchEvent {
  const keys = new Set(args); // Do not batch duplicates
  const values = this.userService.get(Array.from(keys)).pipe(
    share(),
  );
  return { keys, values };
}

这完全符合您的要求,包括:

  • 错误会传播到批处理调用的收件人,但没有其他人
  • 如果每个人都取消订阅,则可观察项将被取消
  • 如果每个人甚至在请求被解雇之前就取消订阅,就永远不会解雇
  • 可观察对象的行为类似于HttpClient:订阅会触发新的(批处理)数据请求。调用者可以随意传送shareReplay或其他任何内容。所以没有惊喜。

这是一个有效的stackblitz Angular演示:https://stackblitz.com/edit/angular-rxjs-batch-request

尤其是,当您“切换”显示时,请注意行为:您将注意到重新订阅现有的可观察对象将触发新的批处理请求,并且如果您重新执行以下操作,这些请求将被取消(或完全不触发)切换速度足够快。

用例

在我们的项目中,我们将此用于Angular Tables,其中每行需要单独获取其他数据以进行渲染。这使我们能够:

  • 在不需要任何特殊分页知识的情况下,将对“单个页面”的所有请求进行处理
  • 如果用户快速分页,可能一次获取多个页面
  • 即使页面大小发生变化,也可以重复使用现有结果

限制

我不会在其中添加分块或速率限制。由于可观察到的源是哑巴bufferTime,因此您会遇到问题:

  • “重块”将在重复数据删除之前发生。因此,如果您有100个针对单个userId的请求,则最终将仅用1个元素触发多个请求
  • 如果您设置了速率限制,则将无法检查队列。因此,您最终可能会排成很长的队列,其中包含多个相同的请求。

这是一种悲观的观点。修复它意味着要使用状态队列/批处理机制,这要复杂得多。

答案 2 :(得分:1)

我不确定这是否是解决此问题的最佳方法(至少需要测试),但是我将尝试解释我的观点。

我们有2个queue:用于待处理和功能请求。
result有助于向订户传递响应/错误。
基于某种时间表的某种工作者从队列中提取任务来执行请求。

  

如果我取消订阅fetchUser返回的可观察对象   bufferTime的计时器已完成,它不会阻止获取   用户。

fetchUser退订将清除request queue,而worker将无济于事。

  

如果我取消订阅fetchUser返回的所有可观察对象,则之前   批次的提取已完成,不会取消请求。

工作人员订阅until isNothingRemain$

const functionThatSimulateAFetch = (userIds: string[]) => from(userIds).pipe(

  map((userId) => ({ id: userId, name: "George" })),
  toArray(),
  tap(() => console.log('API_CALL', userIds)),
  delay(200),
)

class Queue {
  queue$ = new BehaviorSubject(new Map());

  private get currentQueue() {
    return new Map(this.queue$.getValue());
  }

  add(...ids) {
    const newMap = ids.reduce((acc, id) => {
      acc.set(id, (acc.get(id) || 0) + 1);
      return acc;
    }, this.currentQueue);
    this.queue$.next(newMap);
  };

  addMap(idmap: Map<any, any>) {

    const newMap = (Array.from(idmap.keys()))
      .reduce((acc, id) => {
        acc.set(id, (acc.get(id) || 0) + idmap.get(id));
        return acc;
      }, this.currentQueue);
    this.queue$.next(newMap);
  }

  remove(...ids) {
    const newMap = ids.reduce((acc, id) => {
      acc.get(id) > 1 ? acc.set(id, acc.get(id) - 1) : acc.delete(id);
      return acc;
    }, this.currentQueue)
    this.queue$.next(newMap);
  };

  removeMap(idmap: Map<any, any>) {
    const newMap = (Array.from(idmap.keys()))
      .reduce((acc, id) => {
        acc.get(id) > idmap.get(id) ? acc.set(id, acc.get(id) - idmap.get(id)) : acc.delete(id);
        return acc;
      }, this.currentQueue)
    this.queue$.next(newMap);
  };

  has(id) {
    return this.queue$.getValue().has(id);
  }

  asObservable() {
    return this.queue$.asObservable();
  }
}

class Result {
  result$ = new BehaviorSubject({ ids: new Map(), isError: null, value: null });
  select(id) {
    return this.result$.pipe(
      filter(({ ids }) => ids.has(id)),
      switchMap(({ isError, value }) => isError ? throwError(value) : of(value.find(x => x.id === id)))
    )
  }
  add({ isError, value, ids }) {
    this.result$.next({ ids, isError, value });
  }

  clear(){
    this.result$.next({ ids: new Map(), isError: null, value: null });
  }
}

const result = new Result();
const queueToSend = new Queue();
const queuePending = new Queue();
const doRequest = new Subject();

const fetchUser = (id: string) => {
  return Observable.create(observer => {
    queueToSend.add(id);
    doRequest.next();

    const subscription = result
      .select(id)
      .pipe(take(1))
      .subscribe(observer);

    // cleanup queue after got response or unsubscribe
    return () => {
      (queueToSend.has(id) ? queueToSend : queuePending).remove(id);
      subscription.unsubscribe();
    }
  })
}


// some kind of worker that take task from queue and send requests
doRequest.asObservable().pipe(
  auditTime(1000),
  // clear outdated results
  tap(()=>result.clear()),
  withLatestFrom(queueToSend.asObservable()),
  map(([_, queue]) => queue),
  filter(ids => !!ids.size),
  mergeMap(ids => {
    // abort the request if it have no subscribers
    const isNothingRemain$ = combineLatest(queueToSend.asObservable(), queuePending.asObservable()).pipe(
      map(([queueToSendIds, queuePendingIds]) => Array.from(ids.keys()).some(k => queueToSendIds.has(k) || queuePendingIds.has(k))),
      filter(hasSameKey => !hasSameKey)
    )

    // prevent to request the same ids if previous requst is not complete
    queueToSend.removeMap(ids);
    queuePending.addMap(ids);
    return functionThatSimulateAFetch(Array.from(ids.keys())).pipe(
      map(res => ({ isErorr: false, value: res, ids })),
      takeUntil(isNothingRemain$),
      catchError(error => of({ isError: true, value: error, ids }))
    )
  }),
).subscribe(res => result.add(res))




fetchUser('1').subscribe(console.log);

const subs = fetchUser('2').subscribe(console.log);
subs.unsubscribe();

fetchUser('3').subscribe(console.log);



setTimeout(() => {
  const subs1 = fetchUser('10').subscribe(console.log);
  subs1.unsubscribe();

  const subs2 = fetchUser('11').subscribe(console.log);
  subs2.unsubscribe();
}, 2000)


setTimeout(() => {
  const subs1 = fetchUser('20').subscribe(console.log);
  subs1.unsubscribe();

  const subs21 = fetchUser('20').subscribe(console.log);
  const subs22 = fetchUser('20').subscribe(console.log);
}, 4000)


// API_CALL
// ["1", "3"]
// {id: "1", name: "George"}
// {id: "3", name: "George"}
// API_CALL
// ["20"]
// {id: "20", name: "George"}
// {id: "20", name: "George"}

stackblitz example

答案 3 :(得分:0)

仅供参考,我尝试使用以下答案创建通用批处理任务队列 @buggy和@picci:

import { Observable, Subject, BehaviorSubject, from, timer } from "rxjs"
import { catchError, share, mergeMap, map, filter, takeUntil, take, bufferTime, timeout, concatMap } from "rxjs/operators"

export interface Task<TInput> {
    uid: number
    input: TInput
}

interface ErroredTask<TInput> extends Task<TInput> {
    error: any
}

interface SucceededTask<TInput, TOutput> extends Task<TInput> {
    output: TOutput
}

export type FinishedTask<TInput, TOutput> = ErroredTask<TInput> | SucceededTask<TInput, TOutput>

const taskErrored = <TInput, TOutput>(
    taskFinished: FinishedTask<TInput, TOutput>,
): taskFinished is ErroredTask<TInput> => !!(taskFinished as ErroredTask<TInput>).error

type BatchedWorker<TInput, TOutput> = (tasks: Array<Task<TInput>>) => Observable<FinishedTask<TInput, TOutput>>

export const createSimpleBatchedWorker = <TInput, TOutput>(
    work: (inputs: TInput[]) => Observable<TOutput[]>,
    workTimeout: number,
): BatchedWorker<TInput, TOutput> => (
    tasks: Array<Task<TInput>>,
) => work(
    tasks.map((task) => task.input),
).pipe(
    mergeMap((outputs) => from(tasks.map((task, index) => ({
        ...task,
        output: outputs[index],
    })))),
    timeout(workTimeout),
    catchError((error) => from(tasks.map((task) => ({
        ...task,
        error,
    })))),
)

export const createBatchedTaskQueue = <TInput, TOutput>(
    worker: BatchedWorker<TInput, TOutput>,
    concurrencyLimit: number = 1,
    batchTimeout: number = 0,
    maxBatchSize: number = Number.POSITIVE_INFINITY,
) => {
    const taskSubject = new Subject<Task<TInput>>()
    const cancelTaskSubject = new BehaviorSubject<Set<number>>(new Set())
    const cancelTask = (task: Task<TInput>) => {
        const cancelledUids = cancelTaskSubject.getValue()
        const newCancelledUids = new Set(cancelledUids)
        newCancelledUids.add(task.uid)
        cancelTaskSubject.next(newCancelledUids)
    }
    const output$: Observable<FinishedTask<TInput, TOutput>> = taskSubject.pipe(
        bufferTime(batchTimeout, undefined, maxBatchSize),
        map((tasks) => {
          const cancelledUids = cancelTaskSubject.getValue()
          return tasks.filter((task) => !cancelledUids.has(task.uid))
        }),
        filter((tasks) => tasks.length > 0),
        mergeMap(
            (tasks) => worker(tasks).pipe(
                takeUntil(cancelTaskSubject.pipe(
                    filter((uids) => {
                        for (const task of tasks) {
                            if (!uids.has(task.uid)) {
                                return false
                            }
                        }
                        return true
                    }),
                )),
            ),
            undefined,
            concurrencyLimit,
        ),
        share(),
    )
    let nextUid = 0
    return (input$: Observable<TInput>): Observable<TOutput> => input$.pipe(
        concatMap((input) => new Observable<TOutput>((observer) => {
            const task = {
                uid: nextUid++,
                input,
            }
            const subscription = output$.pipe(
                filter((taskFinished) => taskFinished.uid === task.uid),
                take(1),
                map((taskFinished) => {
                    if (taskErrored(taskFinished)) {
                        throw taskFinished.error
                    }
                    return taskFinished.output
                }),
            ).subscribe(observer)
            subscription.add(
                timer(0).subscribe(() => taskSubject.next(task)),
            )
            return () => {
                subscription.unsubscribe()
                cancelTask(task)
            }
        })),
    )
}

以我们的示例为例:

import { from } from "rxjs"
import { map, toArray } from "rxjs/operators"
import { createBatchedTaskQueue, createSimpleBatchedWorker } from "mmr/components/rxjs/batched-task-queue"

const functionThatSimulateAFetch = (userIds: string[]) => from(userIds).pipe(
    map((userId) => ({ id: userId, name: "George" })),
    toArray(),
)

const userFetchQueue = createBatchedTaskQueue(
    createSimpleBatchedWorker(
        functionThatSimulateAFetch,
        10000,
    ),
)

const fetchUser = (userId: string) => {
    return from(userId).pipe(
        userFetchQueue,
    )
}

我愿意接受任何改进建议