RxJs难题:共享流并在新订阅上有条件地重试

时间:2018-03-02 12:29:43

标签: angular rxjs

上下文

我们的网络应用可以同时显示不同的帮助面板 当面板需要给定ID(对于前help-id-1)时,我们会获得一个API,并传递该ID,然后我们会返回所需的帮助。

现在,由于某些原因,我们可能会显示相同帮助面板的2次或更多次。但是,我们当然不希望针对相同的项目多次访问API,如果获取没有错误或当前正在获取。

我们的“制作人”为我们提供了一个冷流来检索它:

const getHelpContentById = (id: string) => fromPromise(
  httpCallToGetHelpResultFromThirdLib(id)
).pipe(
  catchError(error => of({ status: 'ERROR', item: null, id })),
  // extracting the body of the response
  map(getHelpItemFromResponse),
  // wrapping the response into an object { status: 'SUCCES', item, id }
  map(item => setStatusOnHelpItem(item, id)),
  startWith({ status: 'LOADING', item: null, id }),
)

我们使用包含状态的对象启动流,稍后我们会收到另一个具有SUCCESSERROR新状态的对象。

预期的解决方案应该:
  - 在给定ID的第一次调用时从API获取   - 如果在上一个订阅完成之前对同一个ID进行了另一个订阅(状态LOADING),则应该获得与第一次呼叫相同的流,而不用再次获取API   - 如果应用的某个部分显示help-id-1并且失败,则流不会关闭,而是next类型为{ status: 'ERROR', item: null, id }的值,这样,如果另一个组件再次尝试显示help-id-1,因为该ID的最后状态为ERROR,它应该再次尝试访问API,并且两个订阅者都应该接收实时更新{ status: 'LOADING', item: null, id }然后是错误或成功

首先尝试:
这是我提出的第一个非RxJs的做法:
(从服务中提取的代码,即类)

private helpItems: Map<
  string,
  { triggerNewFetchForItem: () => void; obs: Observable<HelpItemWithStatus> }
> = new Map();

private getFromCacheOrFetchHelpItem(id: string): Observable<HelpItemWithStatus> {
  let triggerNewFetchForItem$: BehaviorSubject<HelpItemWithStatus>;
  const idNotInCache = !this.helpItems.has(id);

  if (idNotInCache) {
    triggerNewFetchForItem$ = new BehaviorSubject<HelpItemWithStatus>(null);

    this.helpItems.set(id, {
      triggerNewFetchForItem: () => triggerNewFetchForItem$.next(null),
      obs: triggerNewFetchForItem$.pipe(
        switchMap(() => getHelpContentById(id)),
        shareReplay(1),
      ),
    });

    return this.helpItems.get(id).obs;
  } else {
    return this.helpItems.get(id).obs.pipe(
      tap(item => {
        if (item.status === ContentItemStatus.ERROR) {
          this.helpItems.get(id).triggerNewFetchForItem();
        }
      })
    );
  }
}

public getHelpItemById(id: string): Observable<HelpItemWithStatus> {
  return this.getFromCacheOrFetchHelpItem(id);
}

尝试我的同事:

private getFromCacheOrFetchHelpItem4(id: string): Observable<HelpItemWithStatus> {
  let item = this.items.get(id);

  if (item && item.status !== ContentItemStatus.ERROR) {
    return of(item);
  }

  return getNewWrappedHelpItem(this.contentfulClient, id).pipe(
    tap(item => this.items.set(id, bs), 
    shareReplay(1)),
  )
}

问题
  - 如果您订阅一次,最终会出现错误   - 您从新组件再次订阅它   - 它按预期进行另一次提取但是改变了参考文献
  - API调用成功,第二个组件更新,第一个组件不是

结论: 我确信有更好的Rx方式可以做到这一点,甚至可能不依赖于“外部缓存”(这里的Map)。显然,最好有一个新的运算符:)

2 个答案:

答案 0 :(得分:3)

我的工作基本相同。我已经考虑了一段时间,但最后决定对此采取行动。我的解决方案有点粗糙,但我会在完善它时尝试更新答案。我喜欢有关更好的方法的反馈意见。

问题/步骤

这是一个相当复杂的问题,所以我将逐步建立它。为简单起见,我们将从没有参数的api方法开始。

共享流,以便多个订阅者不会触发多个api请求

只需在末尾添加share()运算符即可进行多播。

return api().pipe(share());

分享来自api的最后一个回复

只需将share()更改为shareReplay(1)即可。该参数表示先前要共享的响应数。我们只希望发出最后一个,所以我们放1

或者,您可以使用tap运算符来保留对最后一个发布值的引用,并执行of(data)而不是在最后一个成功时返回流。这只适用于你再也不想再调用api(就像OP所说的那样)但我保持通用性以便灵活地用于其他解决方案。

return api().pipe(shareReplay(1));

如果最后一个请求不成功,则允许新订阅触发新的api请求

这是一种愚蠢的行为。很容易获得最后一个值,甚至为新订户重新运行流。但这并没有使以前的订户受益。您可能会获得成功的结果,但不会通知以前的订阅者。基本上你要求的是让你的主题在有新订阅时发出新值。据我所知,这是不可能的。

我的工作是设置我自己的主题,每次有人请求流时我都可以触发。这不是一回事,但我认为这真的是我们想要的。我们真正想要的是一些重试的方法。如果使用retryWhen运算符没有自动化,那么我们需要一些手动方式,例如新的组件加载。当一个新组件加载时,他们会请求流,以便找到它。

所以我们创建一个主题并在超时时调用next。我宁愿使用ReplaySubjectBehaviorSubject来避免超时,但是当我这样做时,我遇到了角度变化检测的问题(ExpressionChangedAfterItHasBeenCheckedError)。我需要深入了解它。

请注意,share位于外部流中。我们想分享那个而不是内心的。另请注意,我使用switchMap而不是switchMapTo,因为我们每次都想要一个新的内部流。

const trigger = new Subject<void>();
setTimeout(() => trigger.next());
return trigger.pipe(
  switchMap(() => api()),
  shareReplay(1)
);

错误后保持流存活

catchError运算符可让您返回一个observable。由于我们希望这是一条消息,我们只需catchError(e => of(e))。问题是这会结束流。解决方法是将捕获物放在switchMap内部,以便内部流可以死亡,外部流可以继续运行。

return trigger.pipe(
  switchMap(() => api().pipe(
    catchError(err => of(err))
  ),
  shareReplay(1)
);

了解api的状态

为此,我们将创建一个具有type属性的通用响应包装器。可能的值包括&#39; FETCHING&#39;&#39; SUCCESS&#39;和&#39; FAILURE&#39;。我们将使用startWith运算符在api调用开始后立即发送提取通知(因此它为什么会在结束时)。

return trigger.pipe(
  switchMap(() => api().pipe(
    map((data) => ({ state: 'SUCCESS', data })),
    catchError(err => of({ state: 'FAILURE', err })),
    startWith({ state: 'FETCHING' })
  ),
  shareReplay(1)
);

一次只允许一个飞行中的请求

基本上我们想要在请求正在进行时不调用触发器。我们可以使用标志或使用distinct运算符执行此操作,并在api调用结算时使用触发器重置它。第二种方法很棘手,因为在构造流时需要引用流。所以我们只使用一个变量,可以将trigger.next()包装在if中,或者在流上放置一个过滤器。我打算做一个过滤器。

private state: string;
...
return trigger.pipe(
  filter(() => this.state !== 'FETCHING'),
  switchMap(() => api().pipe(
    map((data) => ({ state: 'SUCCESS', data })),
    catchError(err => of({ state: 'FAILURE', err })),
    startWith({ state: 'FETCHING' }),
    tap(x => { this.state = x.state; })
  ),
  shareReplay(1)
);

仅重试错误,而不是成功

你要做的就是不要拨打触发器。因此,只需将触发条件更改为状态未初始化或“失败”时。

...
filter(() => this.state == null || this.state === 'FAILURE'),
...

共享相同参数的流

您基本上只需要对参数进行哈希处理,并将其用作地图的关键字。请参阅下面的完整示例。

解决方案

这一切都放在一起。我创建了一个生成api方法的辅助函数。开发人员必须为参数提供api方法和散列方法,因为推断它会过于复杂。

import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { tap, switchMap, map, startWith, catchError, shareReplay, filter } from 'rxjs/operators';
import { of } from 'rxjs/observable/of';

// posible states of the api request
export enum ApiStateType {
  Fetching,
  Success,
  Failure
}

// wrapper for the api status messages
export interface ApiStatus<T> {
  state: ApiStateType;
  params: any[],
  data: T
}

// information related to a stream for a unique set of parameters
interface StreamConfig<T> {
  state: ApiStateType;
  trigger: Subject<void>;
  stream: Observable<ApiStatus<T>>;
}

export function generateCachedApi<T>(
  api: (...params) => Observable<T>,
  generateKey: (...params) => string
): (...params) => Observable<ApiStatus<T>> {
  const cache = new Map<string, StreamConfig<T>>();

  return (...params): Observable<ApiStatus<T>> => {
    const key = generateKey(...params);
    let config = cache.get(key);

    if (!config) {
      console.log(`created new stream (${key})`);
      config = <StreamConfig<T>> { trigger: new Subject<void>() };
      config.stream = config.trigger.pipe(
        filter(() => config.state == null || config.state === ApiStateType.Failure),
        switchMap(() => {
          return api(...params).pipe(
            map((data) => (<ApiStatus<T>>{ state: ApiStateType.Success, params, data })),
            catchError((data, source) => of(<ApiStatus<T>>{ state: ApiStateType.Failure, params, data })),
            startWith(<ApiStatus<T>>{ state: ApiStateType.Fetching, params }),
            tap(x => { config.state = x.state; })
          );
        }),
        tap(x => { console.log('PUBLISH', x)}),
        shareReplay(1),
      );
      cache.set(key, config);
    } else {
      console.log(`returned existing stream (${key})`);
    }
    setTimeout(() => { config.trigger.next() });
    return config.stream;
  }
}

以下是我一起入侵的一个正在运行的示例:https://stackblitz.com/edit/api-cache

  

我确信有更好的Rx方式可以做到这一点,甚至可能不依赖于外部缓存&#34; (这里的地图)。显然,最好有一个新的运算符:)

我创建了一个cacheMap运算符来尝试这样做。我有一个发出api参数的源,cacheMap运算符会为唯一的参数集找到或创建流,并返回mergeMap样式。问题是每个订阅者现在都会订阅该内部可观察对象。所以你必须添加一个过滤器(见下面的alt。解决方案)。

替代解决方案

这是我想到的另一种解决方案。您可以拥有一个主流,而不是维护多个流,而是通过过滤器将其提供给订阅者。

单个流的问题是重放将适用于所有参数。因此,您必须使用无缓冲的重播或自行管理重播。

如果您使用没有缓冲区的重播,那么它将重放从FETCHINGSUCCESS的所有内容,这可能会导致额外的处理,尽管它可能不会被用户注意到。理想情况下,我们会有一个replayByKey运算符,但我还没有编写它。所以现在我只是使用地图。使用地图的问题是我们仍然向已经收到地图的订阅者发出相同的价值。因此,我们向实例流添加distinctUntilChanged运算符。或者,您可以创建实例流,然后在其上放置takeUntil,其中触发器是为成功过滤的实例流,并在关闭之前放置delay(0)以允许最后一个值通过管道。这将完成流,这是好的,因为一旦你成功,你永远不会得到一个新的价值。我选择与众不同,因为如果你想改变它的要求,它可以让你得到一个新值。

我们使用mergeMap代替switchMap,因为我们可以同时处理针对不同参数的广告请求,并且我们不想取消对不同参数的请求。

以下是解决方案:

import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { tap, mergeMap, map, startWith, catchError, share, filter, distinctUntilChanged } from 'rxjs/operators';
import { of } from 'rxjs/observable/of';

// posible states of the api request
export enum ApiStateType {
  Fetching,
  Success,
  Failure
}

// wrapper for the api status messages
export interface ApiStatus<T> {
  state: ApiStateType;
  key: string;
  params: any[];
  data: T;
}

export function generateCachedApi<T>(
  api: (...params) => Observable<T>,
  generateKey: (...params) => string
): (...params) => Observable<ApiStatus<T>> {
  const trigger = new Subject<any[]>();
  const stateCache = new Map<string, ApiStatus<T>>();
  const stream = trigger.pipe(
    map<any[], [any[], string]>((params) => [ params, generateKey(...params) ]),
    tap(([_, key]) => {
      if (!stateCache.has(key)) {
        stateCache.set(key, <ApiStatus<T>> {})
      }
    }),
    mergeMap(([params, key]) => {
      const apiStatus = stateCache.get(key);
      if (apiStatus.state === ApiStateType.Fetching || apiStatus.state === ApiStateType.Success) {
        return of(apiStatus);
      }
      return api(...params).pipe(
        map((data) => (<ApiStatus<T>>{ state: ApiStateType.Success, key, params, data })),
        catchError((data, source) => of(<ApiStatus<T>>{ state: ApiStateType.Failure, key, params, data })),
        startWith(<ApiStatus<T>>{ state: ApiStateType.Fetching, key, params }),
        tap(state => { stateCache.set(key, state); })
      )
    }),
    tap(x => { console.log('PUBLISH', x)}),
    share()
  );

  return (...params): Observable<ApiStatus<T>> => {
    const key = generateKey(...params);
    const instanceStream = stream.pipe(
      filter((response) => response.key === key),
      distinctUntilChanged()
    );
    setTimeout(() => { trigger.next(params) });
    return instanceStream;
  }
}

答案 1 :(得分:3)

让我们分开我们的顾虑并一次一个地处理它们:

  1. 使用初始值将发出的项目转换为{id, status, item}的元数据,而不是允许出现错误,就像RxJS的materialize()
  2. 缓存项目,直到某个条件变为真(如错误)
  3. 订阅正确的api响应
  4. 1。转换为元数据

    我们将创建一个更高阶的函数,它接受你的getHelpContentById()并返回一个可观察的元数据对象,它首先发出'LOADING',然后'SUCCESS'或'ERROR'取决于响应:

    const toMeta = fetch => id =>
      fetch(id)
        .pipe(
          map(response => ({id, status: 'SUCCESS', item: response})),
          catchError(e => [{id, status: 'ERROR',   item: null}]),
          startWith(       {id, status: 'LOADING', item: null}),
        )
    

    2。缓存项目直到条件(例如'ERROR')

    让我们保持缓存无知:

    • 如何从api
    • 获取数据
    • 如何确定是否需要缓存数据

    两个逻辑位都可以作为函数传递给我们的缓存操作符:

    /**
     * `predicate` returns a boolean
     * `action`    returns an observable
     */
    const cacheUntil = (predicate, action) => {
      const cache = new Map()
    
      // Use our predicate to check if we should uncache an item
      //
      const cacheUpdate = key => cachedValue => {
        if (predicate(cachedValue)) cache.delete(key)
      }
    
      // Prep an item, then cache it
      //
      const cache_set = key => {
        const out$ = action(key)   // fetching it in our case
          .pipe(
            tap(cacheUpdate(key)), // see cacheUpdate above this function
            shareReplay(1)         // make Observable returned from `action` 
                                   // a Hot observable
                                   // so that we don't fetch 
                                   // on new subscriptions
          )
        cache.set(key, out$)
        return out$
      }
    
      // Glue it all together
      //
      return pipe(
        mergeMap(key => // flatten the cached Observable returned from `action()`
          cache.has(key)
            ? cache.get(key)
            : cache_set(key)
        )
      )
    }
    
    // make a stream of help ids to fetch from api
    // that we can call `next(helpId)` on
    const helpId$ = new Subject()
    
    // use a higher-order function to make our apiCall
    // return our metadata object of `{id, status, item}`
    const help$ = helpId$.pipe(
      cacheUntil(hasMetaError, toMeta(getHelpContentById))
    )
    

    我们的第一个参数的谓词,相当不言自明:

    const hasMetaError = meta => 
      meta.status === 'ERROR'
    

    有了这个,我们可以通过一个主题推送帮助ID并获得一个可观察到的缓存api调用:

    const helpIds$ = new Subject()
    
    const help$ = helpId$.pipe(
      cacheUntil(hasMetaError, toMeta(apiCall$))
    )
    
    // When we need a panel...
    //
    const addPanel(panel, helpId) => {
      helpIds$.subscribe(data => panel.display(data))
      helpIds$.next('help-id-999')
    }
    

    3。订阅正确的api响应

    但是等等!每当我们next(helpId)时,每个面板都会通过订阅接收缓存中的每个项目。

    所以让我们过滤它。

    const addPanel(panel, helpId) => {
      helpIds$
        .filter(meta => meta.id === helpId)
        .subscribe(data => panel.display(data))
      helpIds$.next('help-id-999')
    }
    

    就是这样。这是一个运行的例子: https://stackblitz.com/edit/rxjs-cache-until

    复用性

    通过分离出元数据部分,缓存操作符在其他情况下变得更加可重用:

    type Porridge = {celsius: number}
    
    const makePorridge = celsius => {
      console.log("Make porridge at " + celsius + " celcius") 
      return Observable.of({celsius})
    }
    
    const justRight = (p: Porridge) => p.celsius >= 60 && p.celsius <= 70
    
    const porridge$ = new Subject<number>()
    const cachedPorridge$ = porridge$.pipe(
      cacheUntil(justRight, makePorridge)
    )
    
    let temperature = 30
    const addBear = () => {
      cachedPorridge$.subscribe(() => console.log('Add bear'))
      porridge$.next(temperature += 10)
    }
    
    for(var i = 10; i; i--) {
      addBear()
    }
    // between the 60˚ and 70˚ 
    // `makePorridge` gets called for every new bear
    // because Goldilocks keeps eating it all