RxJS 错误处理 Operators (1) - catchError / finalize / retry / retryWhen

今天来介绍一些跟「错误处理」有关的 operators。在使用 RxJS 时,资料流是透过 pipe 及各式各样的 operators 在处理,且很多时候是非同步的,因此大多时候发生错误并不能单纯的使用 try...catch 方式处理,就需要透过这些错误处理相关的 operators 来帮忙罗!

catchError

catchError 可以在来源 Observable 发生错误时,进行额外的处理,一般来说发生错误时,都是在订阅时使用处理:

interval(1000)
  .pipe(
    map(data => {
      if (data % 2 === 0) {
        return data;
      } else {
        throw new Error('发生错误');
      }
    }),
  )
  .subscribe({
    next: data => {
      console.log(`catchError 示范 (1): ${data}`);
    },
    error: error => {
      console.log(`catchError 示范 (1): 错误 - ${error}`);
    }
  });
// catchError 示范 (1): 0
// catchError 示范 (1): 错误 - Error: 发生错误
// (发生错误,整个资料流中断)

弹珠图:

---0---#

但订阅毕竟不是整个 Observable 资料流的一部份,而是我们订阅时自己撰写的逻辑,如果要将错误处理也视为整个 Observable 的一部份,就可以使用 catchErrorcatchError 内会传入错误讯息,且需要回传另一个 Observable,当过程中错误发生时,就会改成使用 catchError 回传的 Observable,让後续的其他 operators 可以继续下去,而不会中断整个资料流:


interval(1000)
  .pipe(
    map(data => {
      if (data % 2 === 0) {
        return data;
      } else {
        throw new Error('发生错误');
      }
    }),
    catchError(error => {
      return interval(1000);
    }),
  	map(data => data * 2)
  )
  .subscribe({
    next: data => {
      console.log(`catchError 示范 (2): ${data}`);
    },
    error: error => {
      console.log(`catchError 示范 (2): 错误 - ${error}`);
    }
  });
// catchError 示范 (2): 0
// (这时候来源 Observable 发生错误,用另一个 Observable 取代)
// (以下是错误处理後新的 Observable)
// catchError 示范 (2): 0
// catchError 示范 (2): 2
// catchError 示范 (2): 4

弹珠图:

           ---0---#
catchError(---0---1---2...)
           ---0-------0----1----2...
                  ^ 发生错误,换成 catchError 内的 Observable
       map(data => data * 2)
           ---0-------0----2----4...

如果遇到不能处理的问题,也可以就让错误发生,此时只需要回传 throwError 即可:

interval(1000)
  .pipe(
    map(data => {
      if (data % 2 === 0) {
        return data;
      } else {
        throw new Error('发生错误');
      }
    }),
    catchError(error => {
      if(error === null) {
        return interval(1000);
      }
      return throwError(error);
    })
  )
  .subscribe({
    next: data => {
      console.log(`catchError 示范 (3): ${data}`);
    },
    error: error => {
      console.log(`catchError 示范 (3): 错误 - ${error}`);
    }
  });
// catchError 示范 (3): 0
// catchError 示范 (3): 错误 - Error: 发生错误
// (发生错误,整个资料流中断)

在 Observable 中,不论是 throw new Error() 还是回传 throwError() 都会产生错误并中断资料流,所以前面程序使用 map 处理错误的逻辑也可以改成:

switchMap(data => iif(() => data % 2 === 0, of(data), throwError('发生错误')))

会更有 functional programming 的风格!

程序码:https://stackblitz.com/edit/mastering-rxjs-operator-catcherror

retry

当 Observable 发生错误时,可以使用 retry 来重试整个 Observable,在 retry 内可以指定重试几次:

interval(1000)
  .pipe(
    switchMap(data => 
      iif(() => data % 2 === 0, of(data), throwError('发生错误'))),
    map(data => data + 1),
    retry(3),
  )
  .subscribe({
    next: data => {
      console.log(`retry 示范 (1): ${data}`);
    },
    error: error => {
      console.log(`retry 示范 (1): 错误 - ${error}`);
    }
  });
// retry 示范 (1): 1
// (发生错误,重试第 1 次)
// retry 示范 (1): 1
// (发生错误,重试第 2 次)
// retry 示范 (1): 1
// (发生错误,重试第 3 次)
// retry 示范 (1): 1
// (发生错误,已经重试 3 次了,不在重试,直接让错误发生)
// retry 示范 (1): 错误 - 发生错误

弹珠图:

---1---#
retry(3)
---1------1------1------1---#
       ^ 发生错误,重试第 1 次
              ^ 发生错误,重试第 2 次
                     ^ 发生错误,重试第 3 次
                            ^ 不在重试,直接让错误发生

若不指定次数,预设为 -1,代表会持续重试;若不想重试,也可以指定次数为 0,就会直接让错误发生。

程序码:https://stackblitz.com/edit/mastering-rxjs-opereator-retry

retryWhen

retryWhen 也可以再发生错误时进行重试,但 retryWhen 更有弹性,在 retryWhen 内需要设计一个 notifier callback function,retryWhen 会将错误资讯传入 notifier function,同时需要回传一个 Observable,retryWhen 会订阅这个 Observable,每当有事件发生时,就进行重试,直到这个回传的 Observable 结束,才停止重试。

以下程序在错误发生时,会每三秒重试一次,共重试三次:

interval(1000)
  .pipe(
    switchMap(data => 
      iif(() => data % 2 === 0, of(data), throwError('发生错误'))),
    map(data => data + 1),
    retryWhen((error) => interval(3000).pipe(take(3)))
  )
  .subscribe({
    next: data => {
      console.log(`retryWhen 示范 (1): ${data}`);
    },
    error: error => {
      console.log(`retryWhen 示范 (1): 错误 - ${error}`);
    },
    complete: () => {
      console.log('retryWhen 示范 (1): 完成');
    }
  });
// retryWhen 示范 (1): 1
// retryWhen 示范 (1): 1
// retryWhen 示范 (1): 1
// (重试的 Observable 完成,因此整个 Observable 也完成)
// retryWhen 示范 (1): 完成

弹珠图:

-1-#
retryWhen(---0---1---2|)
-1----1----1----1|
   ^ 发生错误,三秒後重试
                ^ 重试的 Observable 完成,因此整个 Observable 也完成

由於是让重试的 Observable 完成,因此整个资料流也会当作「完成」,处理订阅的 complete() callback。

如果希望重试几次次後发生错误,一样加入 throwError 即可:

const retryTimesThenThrowError = (every, times) => interval(every).pipe(
  switchMap((value, index) => 
    iif(() => index === times, throwError('重试後发生错误'), of(value)))
  );

interval(1000)
  .pipe(
    switchMap(data => 
      iif(() => data % 2 === 0, of(data), throwError('发生错误'))),
    map(data => data + 1),
    retryWhen((error) => retryTimesThenThrowError(3000, 3))
  )
  .subscribe({
    next: data => {
      console.log(`retryWhen 示范 (2): ${data}`);
    },
    error: error => {
      console.log(`retryWhen 示范 (2): 错误 - ${error}`);
    },
    complete: () => {
      console.log('retryWhen 示范 (2): 完成');
    }
  });
// retryWhen 示范 (2): 1
// retryWhen 示范 (2): 1
// retryWhen 示范 (2): 1
// retryWhen 示范 (2): 1
// retryWhen 示范 (2): 错误 - 重试後发生错误

另外一个小技巧,我们也可以让使用者自己决定何时要重试:

const click$ = fromEvent(document, 'click');
interval(1000)
  .pipe(
    switchMap(data => 
      iif(() => data % 2 === 0, of(data), throwError('发生错误'))),
    map(data => data + 1),
    retryWhen((error) => click$)
  )
  .subscribe({
    next: data => {
      console.log(`retryWhen 示范 (3): ${data}`);
    },
    error: error => {
      console.log(`retryWhen 示范 (3): 错误 - ${error}`);
    },
    complete: () => {
      console.log('retryWhen 示范 (3): 结束');
    }
  });

以上程序码能在错误发生後,当滑鼠 click 事件发生时,才进行重试。

程序码:https://stackblitz.com/edit/mastering-rxjs-operator-retrywhen

finalize

finalize 会在整个来源 Observable 结束时,才进入处理,因此永远会在最後才呼叫到:

interval(1000)
  .pipe(
    take(5),
    finalize(() => {
      console.log('finalize 示范 (1): 在 pipe 内的 finalize 被呼叫了')
    }),
    map(data => data + 1),
  )
  .subscribe({
    next: data => {
      console.log(`finalize 示范 (1): ${data}`);
    },
    complete: () => {
      console.log(`finalize 示范 (1): 完成`);
    }
  });
// finalize 示范 (1): 1
// finalize 示范 (1): 2
// finalize 示范 (1): 3
// finalize 示范 (1): 4
// finalize 示范 (1): 5
// finalize 示范 (1): 完成
// finalize 示范 (1): 在 pipe 内的 finalize 被呼叫了

从结果可以看到,尽管 map 放在 finalize 後面,但还是不断的处理 map 内的逻辑,直到来源 Observable 结束後,才进入 finalize 处理,同时也可以注意到 finalize 会比 subsribecomplete 还慢进入。

严格来说 finalize 不算是错误处理的 operator,因为 finalize 会在整个 Observable 结束时才进入处理,跟有没有发生错误无关,但经常与错误处理搭配一起使用。

interval(1000)
  .pipe(
    switchMap(data => 
      iif(() => data % 2 === 0, of(data), throwError('发生错误'))),
    // 当之前的 operator 发生错误时,资料流会中断,但会进来 finalize
    finalize(() => {
      console.log('finalize 示范 (2): 在 pipe 内的 finalize 被呼叫了')
    }),
    // 当之前的 operator 发生错误时,这里就不会呼叫了
    map(data => data + 1),
  )
  .subscribe({
    next: data => {
      console.log(`finalize 示范 (2): ${data}`);
    },
    error: error => {
      console.log(`finalize 示范 (2): 错误 - ${error}`);
    }
  });
// finalize 示范 (2): 1
// finalize 示范 (2): 错误 - 发生错误
// finalize 示范 (2): 在 pipe 内的 finalize 被呼叫了

从结果可以看到,finalize 也会比 subsribeerror 还慢被呼叫。透过 finalize 我们可以确保就算过程中发生错误导致整个资料流中断,还会有个地方可以处理些事情。

程序码:https://stackblitz.com/edit/mastering-rxjs-operator-finalize

本日小结

  • catchError:可以用来决定当来源 Observable 发生错误时该如何进行,回传一个 Observable 代表会使用此 Observable 继续下去,因此回传 throwError 则代表依然发生错误。
  • retry:当来源 Observable 发生错误时,重新尝试指定次数。
  • retryWhen:当来源 Observable 发生错误时,可以照自定的 Observable 来决定重试的时机。
  • finalize:在 Observable 结束时,无论是 error() 还是 complete(),最後都可以进入 finalize 进行最终处理。

相关资源


<<:  Day 32:来呼叫星战 Profile List 下一页吧(1/2)

>>:  第 29 型 - 单元测试 (Unit Testing)

[Day 16] Leetcode 763. Partition Labels (C++)

前言 今天要解的题目是top 100 liked里面的763. Partition Labels这题...

Day 16. slate × Interfaces × CustomType

slate 将 typescript 的型别扩充相关的内容都集合在 interfaces/cust...

AIS3 Pre-Exam + MFCTF

时间来到今年四月,Dennis 学长跑来问我有没有兴趣打 MyFirst CTF 以及 AIS3 P...

android studio 30天学习笔记-day 21 -获得日期

一般在使用资料库新增资料的时候,都会看到新建资料的日期跟时间,今天会再sqllite上加入日期。 我...

【Day 5_ Arm Mali GPU家族究竟是何方神圣_下篇】

延续上篇还没介绍完的Arm Mali GPU系列解决方案,今天要来接着介绍Mali-G510 GPU...