Angular 使用 RxJS 实现过期 token 刷新并重试


刷新重试策略

如果 API 返回 access_token 失效,那么从 API 获取新的 access_token,并重试失败的请求。 如果 refresh_token 也失效,让用户去登录。

实现过程

使用 Angular 的拦截器可以拦截 API 请求,可以在请求前后做些处理。要使用拦截器,只需要实现 HttpInterceptor 接口的 intercept 方法。一个不做任何处理的拦截器是下面这个样子。

示例:

import { Injectable } from '@angular/core';
import {
  HttpEvent, HttpInterceptor, HttpHandler, HttpRequest
} from '@angular/common/http';

import { Observable } from 'rxjs';

@Injectable()
export class NoopInterceptor implements HttpInterceptor {

  intercept(req: HttpRequest<any>, next: HttpHandler):
    Observable<HttpEvent<any>> {
    return next.handle(req);
  }
}

token 刷新就是在 intercept 方法里面做的。

可以在请求发出前对 HttpRequest 进行处理,比如获取请求头中的数据或者更改请求等等。

示例:

intercept(req: HttpRequest<any>, next: HttpHandler):
    Observable<HttpEvent<any>> {
    const token = req.headers.get('Authorization');
    console.log(token);

    return next.handle(req);
}

intercept 方法返回的是一个 Observable。因此,对 response 做后置处理可以使用 rxjs6 的 pipe

示例:

intercept(req: HttpRequest<any>, next: HttpHandler):
    Observable<HttpEvent<any>> {
    return next.handle(req).pipe(some code...);
}

我在项目中,对 Angular 的 HttpClient 做了一次封装,对外提供了一个 HttpService。 请求头的前置处理,比如设置 token 等的处理放在了 HttpService 中了: 示例:

...
let headers = new HttpHeaders();

headers.append('Authorization', ''[token]);
...

所以在处理 token 刷新的拦截器中,只需判断 API 请求是否设置了 token 信息就可以筛选出需要处理的请求了,需要处理的对 response Observable 使用 pipe,不需要的就直接返回 next.handle(req)

处理过程用到了 rxjs 的几个操作符:maptapconcatMapretryWhendelay,前期投入的时间也主要是花在了这些操作符上,rxjs 的数据流操作方式与以往开发经验还是有很大不同,需要思维的转换。

复习下这几个操作符。

  • map:对源 Observable 发出的值应用处理,并将结果作为 Observable 发出,常用的操作符,相当于 promisethen
  • tap:没有副作用的进行一些透明地操作,比如打印日志、数据采集可以使用这个操作符;
  • concatMap:这个与 map 的区别:map 订阅后输出 Observable,订阅 concatMap 输出值;

    示例:

      becomeHero() {
        return of('hero');
      }
    
      const source = of(1);
    
      source.pipe(
        map(() => this.becomeHero())
      ).subscribe(res => {
        console.log('map:', res); // 输出:map:Observable {_isScalar: true, _subscribe: ƒ, value: "hero"}
      });
    
      source.pipe(
        concatMap(() => this.becomeHero())
      ).subscribe(res => {
        console.log('concatMap:', res); // 输出:concatMap:
    hero
      });
    
  • retryWhen:出错后重试修改后的源;
  • delay:延迟源 Observable 的发送。

按照刷新策略,Angular 中使用 rxjs 实现的重试逻辑如下所示。在 Angular 中间件拦截器中,先筛选出需要刷新控制的请求。这里使用 rxjs 的 map 操作符判断此请求是否需要刷新,需要刷新的抛出错误,以被 retryWhen 操作符接收做重试处理,否则,返回正确的结果。由于是 token 失效的请求重试,所以 retryWhen 里面需要获取新的 token 并重新设置到此请求当中,否则依然用旧的 token 尝试请求是一定会失败的。这里是放在了 tap 操作符当中处理的。然后在 map 操作符当中控制下刷新的次数。最后通过 delay 操作符设置下重试的时间间隔,整个逻辑就完成了。

示例:

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<any> {

// 判断是否是需要校验的请求,不是的直接放行
if (!req.headers.get('Authorization')) {
  return next.handle(req);
}

const response = next.handle(req)
  .pipe(
    map((res: HttpResponse<any>) => {
      // 判读是否需要重试,抛出错误的请求会进入 retryWhen 走重试逻辑,否则返回结果
      if (Lhas(res, 'body.status') && Lget(res, 'body.status') === 401) {
        throw res;
      }

      return res;
    }),
    retryWhen(error => {
      return error
        .pipe(
          concatMap(() => this.handleToken()), // handleToken 是获取 token 的逻辑,代码贴到下面
          tap(token => {
            // 重新设置 token
            const headersMap = new Map();

            headersMap.set('authorization', [`${token}`]);

            Lset(req, 'headers.headers', headersMap);

            return req;
          }),
          map((res, count) => {
            // 重试次数控制
            if (count > 0) {
              throw res;
            }

            return res;
          }),
          delay(1000), // 延迟重试
        );
    }
    )
  );

上面代码中获取 token 的处理逻辑贴在下面。这段代码逻辑处理了一个页面中多个请求都 token 失效的情况,这种情况下会造成发送多个 refreshToken 请求,这样不仅仅会造成资源的浪费,最重要的是可能会导致第一个获取到 token 的请求重试时,第二个 refreshToken 请求也成功了,然后第一个重试就因 token 被刷新调而失败了。这里是通过定义一个刷新时间间隔(timeDebounce)来控制的。将 token 放在本地存储中(cookie 或者 localStorage),如果是间隔时间内的请求就直接本地取 token 返回,超过间隔时间的请求才从网络获取 token。间隔时间需要根据页面接口调用数量及处理时间来确定,比如设置为10秒的意思是所有的请求都会在10秒内发出,这几个请求都设置了新的 token 并发出了请求。

示例:

/**
* 刷新 token
* @param req
*/
handleToken(): Observable<any> {
  const currentTime = new Date().getTime();
  const accessTokenFromLocal = getAccessTokenFromLocal();
  const reGetTokenFromNet = this.refreshToken() // 请求接口,刷新 token
    .pipe(
      map(this.saveToken.bind(this))
    );

  // 没有 refreshTokenTime, 或
  // 没有 accessTokenFromLocal,或
  // 重试间隔大于 timeDebounce 秒,从网络获取,并记录时间
  if (!this.refreshTokenTime ||
    !accessTokenFromLocal ||
    currentTime - this.refreshTokenTime > this.timeDebounce) {
    this.refreshTokenTime = currentTime;
    return reGetTokenFromNet;
  } else {
    return accessTokenFromLocal;
  }
}

到此为止,整个 token 的刷新重试流程就完成了。

技术 Angular RxJS token 刷新