使用 @ngify/http 响应式 HTTP 客户端处理常见的请求场景

2,411 阅读9分钟

在上次的《这一次,放下 axios,使用基于 rxjs 的响应式 HTTP 客户端》文章中,我们简单的介绍了响应式 HTTP Client 的一些基本用法。

这次将主要介绍 “使用响应式 HTTP Client 处理常见的请求场景”。在阅读本文之前,请确保你对响应式 HTTP Client 有一定的了解。

本文列举了开发中一些常见的请求场景,如果你还有其他场景,欢迎在评论区留言✍️。

本文的用例不仅适用于 @ngify/http,也同样适用于 Angular HttpClient

并行请求

当存在多个请求的时候,我们可能希望将全部请求同时发出,并在所有或每个请求响应成功后执行一些操作,这时候可以使用 forkJoin() 函数:

import { forkJoin, tap } from 'rxjs';

const requests = [
  http.get('url').pipe(
    // 窃听该请求成功响应时
    tap(o => console.log('I got it', o))
  ),
  http.get('url'),
  ...
];

forkJoin(requests).subscribe(o => {
  console.log('请求全部响应成功', o);
});

并行请求(并发限制)

当我们运行了上面的例子,你会发现所有请求都在同时发起。如果请求量很大,我们可能需要添加并发限制,限制在某一时刻下只能同时并行 N 个请求,这时候可以使用 from() 函数和 mergeAll() 管道操作符:

import { from, mergeAll } from 'rxjs';

const requests = [
  http.get('url'),
  http.get('url'),
  ...
];
const concurrent = 2; // 并发数
from(requests).pipe(
  mergeAll(concurrent)
).subscribe(...);

串行请求

有时候我们在执行多个请求的时候,后一个请求要依赖前一个请求的响应结果或者要严格控制请求的顺序,这时候可以使用 switchMap() 管道操作符:

import { switchMap } from 'rxjs';

http.get('url').pipe(
  switchMap(() => http.get('url')),
  switchMap(() => http.get('url'))
).subscribe(...)

串行请求(批量)

如果我们存在大量的串行请求,且后一个请求不依赖前一个请求的响应结果,只需要控制请求顺序,这时候可以使用 from() 函数和 concatAll() 管道操作符:

import { from, concatAll } from 'rxjs';

const requests = [
  http.get('url'),
  http.get('url'),
  ...
];

from(requests).pipe(
  concatAll()
).subscribe(...);

竞速请求

有时候我们可能会需要并行发出多个请求,并且当其中任意一个请求首先响应成功的时候执行一些操作,也就是我们只想要拿到第一个响应成功的请求的响应结果,这时候可以使用 race() 函数:

import { race } from 'rxjs';

const requests = [
  http.get('url'),
  http.get('url'),
  ...
];
race(requests).subscribe(...);

取消请求

如果你之前使用过 axios 或者 fetch(),取消请求可能是一件麻烦事儿;但在这里,取消请求是轻而易举的🤌:

import { Subject, takeUntil } from 'rxjs';

// 方法一:取消订阅
const subscription = http.get('url').subscribe(...);
subscription.unsubscribe(); // 取消订阅即可取消请求

// 方法二:通过另一个可观察对象来控制
const notifier = new Subject<void>();
http.get('url').pipe(
  takeUntil(notifier)
).subscribe(...);

notifier.next(); // 发送通知,取消请求

节流与防抖

假设我们有一个按钮,点击按钮发送请求,我们可以通过节流或防抖来控制这些请求:

import { debounceTime, throttleTime } from 'rxjs';

// 使用 debounceTime,对点击事件防抖 500ms,进而控制请求
click$.pipe(
   debounceTime(500),
   switchMap(() => http.get('url'))
).subscribe();

// 使用 throttleTime,对点击事件节流 500ms,进而控制请求
click$.pipe(
   throttleTime(500),
   switchMap(() => http.get('url'))
).subscribe();

有时候我们需要确保同一时间内只有一个请求在进行,可以选择以下两种方案:

// 使用 switchMap,每次点击时,取消旧的请求,并发起新的请求
click$.pipe(
   switchMap(() => http.get('url'))
).subscribe();

// 使用 exhaustMap,每次点击时,如果当前有正在运行的请求,则忽略本次点击,并复用这个请求
// 如果当前没有正在运行的请求,则发起新的请求
click$.pipe(
   exhaustMap(() => http.get('url'))
).subscribe();

async/await

有时候我们可能想要将 rxjsObservable 转为 Promise 以使用 async/await 语法,这时候可以使用 lastValueFrom() 函数 或 forEach() 方法:

import { lastValueFrom } from 'rxjs';

async function fn() {
  const data = await lastValueFrom(http.get('url'));
  // or
  await http.get('url').forEach(data => { /* do something */ });
}

如果你只是想把 Observable 转为 Promise 来和其他现有的 Promise 组合使用,而不使用 async/await 语法,不妨试试将其他现有的 Promise 转为 Observable 来和现有的 Observable 组合使用(有点绕口🤔?再读一遍😆)

请求重试

由于各种原因导致请求响应失败是常有的事儿,在 rxjs 中主要使用 retry() 管道操作符来进行重试,下面列举出几种基本重试方式:

立即重试

请求失败时立即重试,共重试三次:

import { retry } from 'rxjs';

const retryCount = 3;
http.get('url').pipe(
  retry(retryCount)
).subscribe(...);

延迟重试

请求失败时进行重试,每次延迟 1000 毫秒后再重试:

import { retry } from 'rxjs';

const retryCount = 3;
http.get('url').pipe(
  retry({ count: retryCount, delay: 1000 })
).subscribe(...);

增强延迟重试

请求失败时进行重试,每次根据当前已重试次数延迟 1000 * 已重试次数 毫秒后再重试:

import { retry, timer } from 'rxjs';

const retryCount = 3;
http.get('url').pipe(
  retry({
    count: retryCount,
    delay: (error, currentCount) => timer(1000 * currentCount)
  })
).subscribe(...);

上传/下载进度

侦听上传/下载进度也是常有的事儿,我们可以通过配置请求选项参数来接收全部 HTTP 事件:

import { HttpEvent, HttpEventType } from '@ngify/http';

http.post('url', data, {
  reportProgress: true, // 开启进度报告
  observe: 'events'     // 接收 HTTP 事件
}).subscribe((event: HttpEvent<unknown>) => {
  switch (event.type) {
    case HttpEventType.Sent:
      console.log('请求开始发送');
      break;

    case HttpEventType.UploadProgress:
      const { total, loaded } = event;
      if (total > 0) {
        const percent = +(loaded / total * 100).toFixed(); // 计算进度
        console.log('上传进度:%i%', percent);
      }
      break;

    case HttpEventType.Response:
      console.log('请求响应成功,数据上传完毕');
      break;
  }
});

侦听下载进度跟上传进度类似,只是将 HttpEventType.UploadProgress 修改为 HttpEventType.DownloadProgress

缓存请求

把可以缓存的请求的响应结果缓存起来是提高应用程序性能的一种手段,下面介绍一种通过 HTTP 拦截器来缓存请求的方法:

  1. 首先,我们需要一个 HttpContextToken,它将会把普通请求标记为可缓存的请求:
/** 缓存令牌,值为缓存时间,单位毫秒,默认为零,不缓存 */
export const HTTP_CACHE_TOKEN = new HttpContextToken(() => 0);
  1. 然后编写一个函数,它将为我们甄别请求是否为可缓存的请求:
import { HttpRequest } from '@ngify/http';

/**
 * 判断请求是否可缓存
 * 一般只有 GET 请求才需要缓存
 * @param request
 */
function isCachable(request: HttpRequest<unknown>): boolean {
  return request.context.has(HTTP_CACHE_TOKEN);
}
  1. 接着设计一个接口,用来表示缓存条目:
import { HttpResponse } from '@ngify/http';

/** 缓存条目 */
export interface CacheEntry {
  /** 请求的响应 */
  response: HttpResponse<unknown>;
  /** 缓存有效期 */
  expire: number;
}
  1. 然后我们再设计一个类,用来管理缓存条目:
import { HttpContextToken, HttpRequest, HttpResponse } from '@ngify/http';

export class CacheService {
  private readonly cacheMap = new Map<string, CacheEntry>();

  /**
   * 获取缓存
   * @param request
   */
  get(request: HttpRequest<unknown>): HttpResponse<unknown> | null {
    // 判断当前请求是否已被缓存,若未缓存则返回null
    const entry = this.cacheMap.get(request.urlWithParams);

    if (!entry) { return null; }
    // 若缓存命中,则判断缓存是否过期;若已过期则返回 null,否则返回请求对应的响应对象
    return Date.now() > entry.expire ? null : entry.response;
  }

  /**
   * 缓存
   * @param request
   * @param response
   */
  put(request: HttpRequest<unknown>, response: HttpResponse<unknown>): void {
    const entry: CacheEntry = {
      response: response,
      expire: Date.now() + request.context.get(HTTP_CACHE_TOKEN)
    };

    // 这里我们将请求的带参数 URL 作为缓存的 KEY,你也可以改成其他的
    this.cacheMap.set(request.urlWithParams, entry);
  }

  /** 清扫过期缓存 */
  clear() {
    this.cacheMap.forEach((entry, key) => {
      if (Date.now() > entry.expire) {
        this.cacheMap.delete(key);
      }
    });
  }

  /**
   * 通过标记(字符串、正则)模糊查询以撤销缓存
   * @param mark
   * @param once 只撤销一个
   */
  revoke(mark: string | RegExp, once?: boolean) {
    for (const key of this.cacheMap.keys()) {
      if (mark instanceof RegExp ? mark.test(key) : key.includes(mark)) {
        this.cacheMap.delete(key);
        if (once) { return; }
      }
    }
  }

  /** 撤销所有缓存 */
  revokeAll() {
    this.cacheMap.clear();
  }
}

export const cacheService = new CacheService(); // 实例化并导出去
  1. 实现一个 HTTP 拦截器,它将用来帮我们缓存请求:
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@ngify/http';
import { of, tap } from 'rxjs';

export class CacheInterceptor implements HttpInterceptor {
  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    // 如果当前请求不可缓存,则将它传递到下一个 HTTP 处理器
    if (!isCachable(request)) {
      return next.handle(request);
    }

    // 缓存命中则直接返回缓存的请求响应
    const response = cacheService.get(request);
    if (response) {
      return of(response);
    }

    // 发送请求,成功后缓存
    return next.handle(request).pipe(
      tap(event => {
        cacheService.clear(); // 顺便清理一下过期缓存
        event instanceof HttpResponse && cacheService.put(request, event);
      })
    );
  }
}
  1. 简单测试一下:
import { HttpClient, HttpContext } from '@ngify/http';
import { switchMap } from 'rxjs';

const cacheInterceptor = new CacheInterceptor();
const http = new HttpClient([cacheInterceptor]);

function getData() {
  return http.get('url', null, {
    context: new HttpContext().set(HTTP_CACHE_TOKEN, 1800000) // 携带 HTTP_CACHE_TOKEN
  });
}

// 这里串行执行两次请求,运行后可以发现只有一次真实请求
getData().pipe(
  switchMap(() => getData())
).subscribe(...);
  1. 如果我们需要主动清除缓存,可以使用前面定义好的 CacheService:
cacheService.revoke('url');

注意:这里使用到的 CacheService 实例与在 CacheInterceptor 中使用到的 CacheService 实例为同一个实例。

不难发现,通过拦截器来实现请求缓存,对于接口调用方来说是无感的。

认证与令牌续签

为接口添加 Authorization 请求头,在 AccessToken 过期时自动续签,也可以在 HTTP 拦截器中实现。下面是一个不完整的示例,缺少的部分已用注释代替,使用时需要替换为你自己的实现:

import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@ngify/http';
import { catchError, share, tap, finalize, switchMap } from 'rxjs';

export class AuthInterceptor implements HttpInterceptor {
  /** 令牌刷新器 */
  private refresher: Observable<string>;

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    // 如果令牌刷新器存在,则等待令牌刷新完成后再继续
    if (this.refresher) {
      return this.waitRefresh(request, next);
    }
          
    const { accessToken, refreshToken } = /* 省略代码:获取储存在本地的 AccessToken 和 RefreshToken */;

    // 如果有 AccessToken,就添加 Authorization 请求头
    if (accessToken) {
      request = request.clone({
        headers: request.headers.set('Authorization', accessToken)
      });
    }

    return next.handle(request).pipe(
      catchError((error: HttpErrorResponse) => {
        // 如果捕获到 401 Unauthorized 错误
        if (error.status === 401) {
          // 如果存在 RefreshToken,则执行 AccessToken 续签,然后等待令牌刷新完成后再继续
          if (refreshToken) {
            this.refreshToken(refresh);
            return this.waitRefresh(request, next);
          }

          /* 省略代码:如果没有 RefreshToken,则退出登录 */
        }

        // 其他情况我们这里不做处理,将异常再次抛出去
        throw error;
      })
    );
  }

  /**
   * 续签访问令牌
   * @param token 续签令牌
   */
  private refreshToken(token: string) {
    this.refresher = http.getAccessToken(token).pipe(
      tap(data => {
        /* 省略代码:将新的 Token 储存到本地 */
      }),
      catchError((error: HttpErrorResponse) => {
        /* 省略代码:如果续签失败了,则退出登录 */
        throw error;
      }),
      finalize(() => this.refresher = null),
      share(), // 关键操作符,它将一个普通的 Obserable 变为可多播的 Observable
    );
  }

  /**
   * 等待令牌续签后再发送请求
   * @param request
   * @param next
   */
  private waitRefresh(request: HttpRequest<unknown>, next: HttpHandler) {
    return this.refresher.pipe(
      switchMap(token => {
        request = request.clone({
          headers: request.headers.set('Authorization', token)
        });

        return next.handle(request)
      })
    );
  }
}

如果你的 Token 储存在 Cookie 当中,并不需要手动设置到 Header,那么我们可以使用 retry() 操作符,编写更少的代码来实现这个拦截器:

import { Observable, catchError, finalize, map, retry, share, switchMap } from 'rxjs';

// 这是一个函数式的拦截器
export function useAuthInterceptor(): HttpInterceptorFn {
  /** 令牌刷新器 */
  let refreshToken$: Observable<unknown> | null = null;

  return (request: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> => {
    const authService = /** 省略代码:AuthService **/;

    // 如果正在续签 Token,则等待续签完成后再发起请求
    if (refreshToken$) {
      return refreshToken$.pipe(
        switchMap(() => next(request))
      );
    }

    return next(request).pipe(
      retry({
        count: 1,
        delay: (error: HttpErrorResponse) => {
          // 如果不是 401 Unauthorized 错误,则不做处理,将错误再次抛出
          if (error.status !== HttpStatusCode.Unauthorized) {
            throw error;
          }

          // 续签访问令牌,使用 `??=` 空值合并运算符,仅在 refreshToken$ 为空时进行赋值
          refreshToken$ ??= authService.refreshToken().pipe(
            catchError(error => {
              /** 省略代码:令牌续签失败,需要重新登录 **/
              throw error;
            }),
            finalize(() => refreshToken$ = null),
            share() // 关键操作符,它将一个普通的 Obserable 变为可多播的 Observable
          );

          // 返回 `refreshToken$`,401 Unauthorized 的请求将在续签成功发出后进行重试
          return refreshToken$;
        }
      })
    );
  };
}

请求超时

由于网络不佳等各种原因导致请求迟迟没有响应,我们一般会将其判定为超时,需要终止这个请求并抛出异常,这时候可以使用 timeout() 管道操作符:

import { timeout } from 'rxjs';

http.get('url').pipe(
  timeout(5 * 1000)
).subscribe(...);

timeout() 管道操作符与 retry() 管道操作符配合使用即可实现请求超时就重试的常见需求:

import { timeout, retry } from 'rxjs';

http.get('url').pipe(
  timeout(5 * 1000),
  retry(3)
).subscribe(...);

捕获错误

如果我们需要在请求发生错误的时候进行错误捕获,处理一些能处理的异常,这时候可以使用 catchError() 管道操作符:

import { of, catchError } from 'rxjs';

http.get('url').pipe(
  catchError(error => {
      if (/* 如果这个错误我能处理 */) {
          return of({ code: 0, msg: 'success' });
      }
      // 否则将错误再次抛出
      throw error;
  })
).subscribe(...);

支持

对 @ngify/http 感兴趣?为该项目点星⭐!@ngify/http