Angular HTTP客戶端

2022-07-09 17:21 更新

使用 HTTP 與后端服務進行通信

大多數前端應用都要通過 HTTP 協議與服務器通訊,才能下載或上傳數據并訪問其它后端服務。Angular 給應用提供了一個 HTTP 客戶端 API,也就是 @angular/common/http 中的 HttpClient 服務類。

HTTP 客戶端服務提供了以下主要功能。

  • 請求類型化響應對象的能力
  • 簡化的錯誤處理
  • 可測試性特性
  • 請求和響應攔截

服務器通訊的準備工作

要想使用 ?HttpClient?,就要先導入 Angular 的 ?HttpClientModule?。大多數應用都會在根模塊 ?AppModule ?中導入它。

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  imports: [
    BrowserModule,
    // import HttpClientModule after BrowserModule.
    HttpClientModule,
  ],
  declarations: [
    AppComponent,
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule {}

然后,你可以把 ?HttpClient ?服務注入成一個應用類的依賴項,如下面的 ?ConfigService ?例子所示。

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable()
export class ConfigService {
  constructor(private http: HttpClient) { }
}

?HttpClient ?服務為所有工作都使用了可觀察對象。你必須導入范例代碼片段中出現的 RxJS 可觀察對象和操作符。比如 ?ConfigService ?中的這些導入就很典型。

import { Observable, throwError } from 'rxjs';
import { catchError, retry } from 'rxjs/operators';
你可以運行本指南附帶的現場演練 / 下載范例。
該范例應用不需要數據服務器。它依賴于 Angular-in-memory-web-api,它替代了 HttpClient 模塊中的 ?HttpBackend?。這個替代服務會模擬 REST 式的后端的行為。
看一下 ?AppModule ?的這些導入,看看它的配置方式。

從服務器請求數據

使用 ?HttpClient.get()? 方法從服務器獲取數據。該異步方法會發(fā)送一個 HTTP 請求,并返回一個 Observable,它會在收到響應時發(fā)出所請求到的數據。返回的類型取決于你調用時傳入的 ?observe ?和 ?responseType ?參數。

?get()? 方法有兩個參數。要獲取的端點 URL,以及一個可以用來配置請求的選項對象。

options: {
  headers?: HttpHeaders | {[header: string]: string | string[]},
  observe?: 'body' | 'events' | 'response',
  params?: HttpParams|{[param: string]: string | number | boolean | ReadonlyArray<string | number | boolean>},
  reportProgress?: boolean,
  responseType?: 'arraybuffer'|'blob'|'json'|'text',
  withCredentials?: boolean,
}

這些重要的選項包括 observe 和 responseType 屬性。

  • observe 選項用于指定要返回的響應內容。
  • responseType 選項指定返回數據的格式。
可以用 ?options ?對象來配置傳出請求的各個方面。比如,在?Adding headers? 中,該服務使用 ?headers ?選項屬性設置默認頭。
使用 ?params ?屬性可以配置帶HTTP URL 參數的請求,?reportProgress ?選項可以在傳輸大量數據時監(jiān)聽進度事件。

應用經常會從服務器請求 JSON 數據。在 ?ConfigService ?例子中,該應用需要服務器 ?config.json? 上的一個配置文件來指定資源的 URL。

{
  "heroesUrl": "api/heroes",
  "textfile": "assets/textfile.txt",
  "date": "2020-01-29"
}

要獲取這類數據,?get()? 調用需要以下幾個選項:?{observe: 'body', responseType: 'json'}?。這些是這些選項的默認值,所以下面的例子不會傳遞 options 對象。后面幾節(jié)展示了一些額外的選項。

這個例子符合通過定義一個可復用的可注入服務來執(zhí)行數據處理功能來創(chuàng)建可伸縮解決方案的最佳實踐。除了提取數據外,該服務還可以對數據進行后處理,添加錯誤處理,并添加重試邏輯。

?ConfigService ?使用 ?HttpClient.get()? 方法獲取這個文件。

configUrl = 'assets/config.json';

getConfig() {
  return this.http.get<Config>(this.configUrl);
}

?ConfigComponent ?注入了 ?ConfigService ?并調用了 ?getConfig ?服務方法。

由于該服務方法返回了一個 ?Observable ?配置數據,該組件會訂閱該方法的返回值。訂閱回調只會對后處理進行最少量的處理。它會把數據字段復制到組件的 ?config ?對象中,該對象在組件模板中是數據綁定的,用于顯示。

showConfig() {
  this.configService.getConfig()
    .subscribe((data: Config) => this.config = {
        heroesUrl: data.heroesUrl,
        textfile:  data.textfile,
        date: data.date,
    });
}

請求輸入一個類型的響應

可以構造自己的 ?HttpClient ?請求來聲明響應對象的類型,以便讓輸出更容易、更明確。所指定的響應類型會在編譯時充當類型斷言。

指定響應類型是在向 TypeScript 聲明,它應該把你的響應對象當做給定類型來使用。這是一種構建期檢查,它并不能保證服務器會實際給出這種類型的響應對象。該服務器需要自己確保返回服務器 API 中指定的類型。

要指定響應對象類型,首先要定義一個具有必需屬性的接口。這里要使用接口而不是類,因為響應對象是普通對象,無法自動轉換成類的實例。

export interface Config {
  heroesUrl: string;
  textfile: string;
  date: any;
}

接下來,在服務器中把該接口指定為 ?HttpClient.get()? 調用的類型參數。

getConfig() {
  // now returns an Observable of Config
  return this.http.get<Config>(this.configUrl);
}

當把接口作為類型參數傳給 ?HttpClient.get()? 方法時,可以使用RxJS map 操作符來根據 UI 的需求轉換響應數據。然后,把轉換后的數據傳給異步管道。

修改后的組件方法,其回調函數中獲取一個帶類型的對象,它易于使用,且消費起來更安全:

config: Config | undefined;

showConfig() {
  this.configService.getConfig()
    // clone the data object, using its known Config shape
    .subscribe((data: Config) => this.config = { ...data });
}

要訪問接口中定義的屬性,必須將從 JSON 獲得的普通對象顯式轉換為所需的響應類型。比如,以下 ?subscribe ?回調會將 ?data ?作為對象接收,然后進行類型轉換以訪問屬性。

.subscribe(data => this.config = {
  heroesUrl: (data as any).heroesUrl,
  textfile:  (data as any).textfile,
});
OBSERVE 和 RESPONSE 的類型
?observe ?和 ?response ?選項的類型是字符串的聯合類型,而不是普通的字符串。
options: {
  …
  observe?: 'body' | 'events' | 'response',
  …
  responseType?: 'arraybuffer'|'blob'|'json'|'text',
  …
}
這會引起混亂。比如:
// this works
client.get('/foo', {responseType: 'text'})

// but this does NOT work
const options = {
  responseType: 'text',
};
client.get('/foo', options)
在第二種情況下,TypeScript 會把 ?options ?的類型推斷為 ?{responseType: string}?。該類型的 ?HttpClient.get? 太寬泛,無法傳給 ?HttpClient.get?,它希望 ?responseType ?的類型是特定的字符串之一。而 ?HttpClient ?就是以這種方式顯式輸入的,因此編譯器可以根據你提供的選項報告正確的返回類型。  

使用 ?as const?,可以讓 TypeScript 知道你并不是真的要使用字面字符串類型:

const options = {
  responseType: 'text' as const,
};
client.get('/foo', options);

讀取完整的響應體

在前面的例子中,對 ?HttpClient.get()? 的調用沒有指定任何選項。默認情況下,它返回了響應體中包含的 JSON 數據。

你可能還需要關于這次對話的更多信息。比如,有時候服務器會返回一個特殊的響應頭或狀態(tài)碼,來指出某些在應用的工作流程中很重要的條件。

可以用 ?get()? 方法的 ?observe ?選項來告訴 ?HttpClient?,你想要完整的響應對象:

getConfigResponse(): Observable<HttpResponse<Config>> {
  return this.http.get<Config>(
    this.configUrl, { observe: 'response' });
}

現在,?HttpClient.get()? 會返回一個 ?HttpResponse ?類型的 ?Observable?,而不只是 JSON 數據。

該組件的 ?showConfigResponse()? 方法會像顯示配置數據一樣顯示響應頭:

showConfigResponse() {
  this.configService.getConfigResponse()
    // resp is of type `HttpResponse<Config>`
    .subscribe(resp => {
      // display its headers
      const keys = resp.headers.keys();
      this.headers = keys.map(key =>
        `${key}: ${resp.headers.get(key)}`);

      // access the body directly, which is typed as `Config`.
      this.config = { ...resp.body! };
    });
}

如你所見,該響應對象具有一個帶有正確類型的 ?body ?屬性。

發(fā)起 JSONP 請求

當服務器不支持 CORS 協議時,應用程序可以使用 ?HttpClient ?跨域發(fā)出 ?JSONP ?請求。

Angular 的 JSONP 請求會返回一個 ?Observable?。遵循訂閱可觀察對象變量的模式,并在使用 async 管道管理結果之前,使用 RxJS ?map ?操作符轉換響應。

在 Angular 中,通過在 ?NgModule ?的 ?imports ?中包含 ?HttpClientJsonpModule ?來使用 JSONP。在以下范例中,?searchHeroes()? 方法使用 JSONP 請求來查詢名稱包含搜索詞的英雄。

/* GET heroes whose name contains search term */
searchHeroes(term: string): Observable {
  term = term.trim();

  const heroesURL = `${this.heroesURL}?${term}`;
  return this.http.jsonp(heroesUrl, 'callback').pipe(
      catchError(this.handleError('searchHeroes', [])) // then handle the error
    );
}

該請求將 ?heroesURL ?作為第一個參數,并將回調函數名稱作為第二個參數。響應被包裝在回調函數中,該函數接受 JSONP 方法返回的可觀察對象,并將它們通過管道傳給錯誤處理程序。

請求非 JSON 數據

不是所有的 API 都會返回 JSON 數據。在下面這個例子中,?DownloaderService ?中的方法會從服務器讀取文本文件,并把文件的內容記錄下來,然后把這些內容使用 ?Observable<string>? 的形式返回給調用者。

getTextFile(filename: string) {
  // The Observable returned by get() is of type Observable<string>
  // because a text response was specified.
  // There's no need to pass a <string> type parameter to get().
  return this.http.get(filename, {responseType: 'text'})
    .pipe(
      tap( // Log the result or error
      {
        next: (data) => this.log(filename, data),
        error: (error) => this.logError(filename, error)
      }
      )
    );
}

這里的 ?HttpClient.get()? 返回字符串而不是默認的 JSON 對象,因為它的 ?responseType ?選項是 ?'text'?。

RxJS 的 ?tap ?操作符(如“竊聽”中所述)使代碼可以檢查通過可觀察對象的成功值和錯誤值,而不會干擾它們。

在 ?DownloaderComponent ?中的 ?download()? 方法通過訂閱這個服務中的方法來發(fā)起一次請求。

download() {
  this.downloaderService.getTextFile('assets/textfile.txt')
    .subscribe(results => this.contents = results);
}

處理請求錯誤

如果請求在服務器上失敗了,那么 ?HttpClient ?就會返回一個錯誤對象而不是一個成功的響應對象。

執(zhí)行服務器請求的同一個服務中也應該執(zhí)行錯誤檢查、解釋和解析。

發(fā)生錯誤時,你可以獲取失敗的詳細信息,以便通知你的用戶。在某些情況下,你也可以自動重試該請求。

獲取錯誤詳情

當數據訪問失敗時,應用會給用戶提供有用的反饋。原始的錯誤對象作為反饋并不是特別有用。除了檢測到錯誤已經發(fā)生之外,還需要獲取錯誤詳細信息并使用這些細節(jié)來撰寫用戶友好的響應。

可能會出現兩種類型的錯誤。

  • 服務器端可能會拒絕該請求,并返回狀態(tài)碼為 404 或 500 的 HTTP 響應對象。這些是錯誤響應。
  • 客戶端也可能出現問題,比如網絡錯誤會讓請求無法成功完成,或者 RxJS 操作符也會拋出異常。這些錯誤會產生 JavaScript 的 ?ErrorEvent ?對象。這些錯誤的 ?status ?為 ?0?,并且其 ?error ?屬性包含一個 ?ProgressEvent ?對象,此對象的 ?type ?屬性可以提供更詳細的信息。

?HttpClient ?在其 ?HttpErrorResponse ?中會捕獲兩種錯誤。可以檢查這個響應是否存在錯誤。

下面的例子在之前定義的 ?ConfigService ?中定義了一個錯誤處理程序。

private handleError(error: HttpErrorResponse) {
  if (error.status === 0) {
    // A client-side or network error occurred. Handle it accordingly.
    console.error('An error occurred:', error.error);
  } else {
    // The backend returned an unsuccessful response code.
    // The response body may contain clues as to what went wrong.
    console.error(
      `Backend returned code ${error.status}, body was: `, error.error);
  }
  // Return an observable with a user-facing error message.
  return throwError(() => new Error('Something bad happened; please try again later.'));
}

該處理程序會返回一個帶有用戶友好的錯誤信息的 RxJS ?ErrorObservable?。下列代碼修改了 ?getConfig()? 方法,它使用一個管道把 ?HttpClient.get()? 調用返回的所有 Observable 發(fā)送給錯誤處理器。

getConfig() {
  return this.http.get<Config>(this.configUrl)
    .pipe(
      catchError(this.handleError)
    );
}

重試失敗的請求

有時候,錯誤只是臨時性的,只要重試就可能會自動消失。比如,在移動端場景中可能會遇到網絡中斷的情況,只要重試一下就能拿到正確的結果。

RxJS 庫提供了幾個重試操作符。比如,?retry()? 操作符會自動重新訂閱一個失敗的 ?Observable ?幾次。重新訂閱 ?HttpClient ?方法會導致它重新發(fā)出 HTTP 請求。

下面的例子演示了如何在把一個失敗的請求傳給錯誤處理程序之前,先通過管道傳給 ?retry()? 操作符。

getConfig() {
  return this.http.get<Config>(this.configUrl)
    .pipe(
      retry(3), // retry a failed request up to 3 times
      catchError(this.handleError) // then handle the error
    );
}

把數據發(fā)送到服務器

除了從服務器獲取數據外,?HttpClient ?還支持其它一些 HTTP 方法,比如 PUT,POST 和 DELETE,你可以用它們來修改遠程數據。

本指南中的這個范例應用包括一個簡略版本的《英雄之旅》,它會獲取英雄數據,并允許用戶添加、刪除和修改它們。下面幾節(jié)在 ?HeroesService ?范例中展示了數據更新方法的一些例子。

發(fā)起一個 POST 請求

應用經常在提交表單時通過 POST 請求向服務器發(fā)送數據。下面這個例子中,?HeroesService ?在向數據庫添加英雄時發(fā)起了一個 HTTP POST 請求。

/** POST: add a new hero to the database */
addHero(hero: Hero): Observable<Hero> {
  return this.http.post<Hero>(this.heroesUrl, hero, httpOptions)
    .pipe(
      catchError(this.handleError('addHero', hero))
    );
}

?HttpClient.post()? 方法像 ?get()? 一樣也有類型參數,可以用它來指出你期望服務器返回特定類型的數據。該方法需要一個資源 URL 和兩個額外的參數:

參數

詳情

body

要在請求正文中 POST 的數據。

options

一個包含方法選項的對象,在這里,它用來指定必要的請求頭。

這個例子捕獲了前面獲取錯誤詳情所講的錯誤。

?HeroesComponent ?通過訂閱該服務方法返回的 ?Observable ?發(fā)起了一次實際的 ?POST ?操作。

this.heroesService
  .addHero(newHero)
  .subscribe(hero => this.heroes.push(hero));

當服務器成功做出響應時,會帶有這個新創(chuàng)建的英雄,然后該組件就會把這個英雄添加到正在顯示的 ?heroes ?列表中。

發(fā)起 DELETE 請求

該應用可以把英雄的 ID 傳給 ?HttpClient.delete? 方法的請求 URL 來刪除一個英雄。

/** DELETE: delete the hero from the server */
deleteHero(id: number): Observable<unknown> {
  const url = `${this.heroesUrl}/${id}`; // DELETE api/heroes/42
  return this.http.delete(url, httpOptions)
    .pipe(
      catchError(this.handleError('deleteHero'))
    );
}

當 ?HeroesComponent ?訂閱了該服務方法返回的 ?Observable ?時,就會發(fā)起一次實際的 ?DELETE ?操作。

this.heroesService
  .deleteHero(hero.id)
  .subscribe();

該組件不會等待刪除操作的結果,所以它的 subscribe(訂閱)中沒有回調函數。不過就算你不關心結果,也仍然要訂閱它。調用 ?subscribe()? 方法會執(zhí)行這個可觀察對象,這時才會真的發(fā)起 DELETE 請求。

你必須調用 ?subscribe()?,否則什么都不會發(fā)生。僅僅調用 ?HeroesService.deleteHero()? 是不會發(fā)起 DELETE 請求的。

// oops ... subscribe() is missing so nothing happens
this.heroesService.deleteHero(hero.id);

別忘了訂閱!

在調用方法返回的可觀察對象的 ?subscribe()? 方法之前,?HttpClient ?方法不會發(fā)起 HTTP 請求。這適用于 ?HttpClient ?的所有方法。

?AsyncPipe ?會自動為你訂閱(以及取消訂閱)。

?HttpClient ?的所有方法返回的可觀察對象都設計為冷的。HTTP 請求的執(zhí)行都是延期執(zhí)行的,讓你可以用 ?tap ?和 ?catchError ?這樣的操作符來在實際執(zhí)行 HTTP 請求之前,先對這個可觀察對象進行擴展。

調用 ?subscribe(…)? 會觸發(fā)這個可觀察對象的執(zhí)行,并導致 ?HttpClient ?組合并把 HTTP 請求發(fā)給服務器。

可以把這些可觀察對象看做實際 HTTP 請求的藍圖。

實際上,每個 subscribe() 都會初始化此可觀察對象的一次單獨的、獨立的執(zhí)行。訂閱兩次就會導致發(fā)起兩個 HTTP 請求。

const req = http.get<Heroes>('/api/heroes');
// 0 requests made - .subscribe() not called.
req.subscribe();
// 1 request made.
req.subscribe();
// 2 requests made.

發(fā)起 PUT 請求

應用可以使用 HttpClient 服務發(fā)送 PUT 請求。下面的 ?HeroesService ?范例(就像 POST 范例一樣)用一個修改過的數據替換了該資源。

/** PUT: update the hero on the server. Returns the updated hero upon success. */
updateHero(hero: Hero): Observable<Hero> {
  return this.http.put<Hero>(this.heroesUrl, hero, httpOptions)
    .pipe(
      catchError(this.handleError('updateHero', hero))
    );
}

對于所有返回可觀察對象的 HTTP 方法,調用者(?HeroesComponent.update()?)必須 ?subscribe()? 從 ?HttpClient.put()? 返回的可觀察對象,才會真的發(fā)起請求。

添加和更新請求頭

很多服務器都需要額外的頭來執(zhí)行保存操作。比如,服務器可能需要一個授權令牌,或者需要 ?Content-Type? 頭來顯式聲明請求體的 MIME 類型。

添加請求頭

?HeroesService ?在一個 ?httpOptions ?對象中定義了這樣的頭,它們被傳給每個 ?HttpClient ?的保存型方法。

import { HttpHeaders } from '@angular/common/http';

const httpOptions = {
  headers: new HttpHeaders({
    'Content-Type':  'application/json',
    Authorization: 'my-auth-token'
  })
};

更新請求頭

你不能直接修改前面的選項對象中的 ?HttpHeaders ?請求頭,因為 ?HttpHeaders ?類的實例是不可變對象。請改用 ?set()? 方法,以返回當前實例應用了新更改之后的副本。

下面的例子演示了當舊令牌過期時,可以在發(fā)起下一個請求之前更新授權頭。

httpOptions.headers =
  httpOptions.headers.set('Authorization', 'my-new-auth-token');

配置 HTTP URL 參數

使用 ?HttpParams ?類和 ?params ?選項在你的 ?HttpRequest ?中添加 URL 查詢字符串。

下面的例子中,?searchHeroes()? 方法用于查詢名字中包含搜索詞的英雄。

首先導入 ?HttpParams ?類。

import {HttpParams} from "@angular/common/http";
/* GET heroes whose name contains search term */
searchHeroes(term: string): Observable<Hero[]> {
  term = term.trim();

  // Add safe, URL encoded search parameter if there is a search term
  const options = term ?
   { params: new HttpParams().set('name', term) } : {};

  return this.http.get<Hero[]>(this.heroesUrl, options)
    .pipe(
      catchError(this.handleError<Hero[]>('searchHeroes', []))
    );
}

如果有搜索詞,代碼會用進行過 URL 編碼的搜索參數來構造一個 options 對象。比如,如果搜索詞是 "cat",那么 GET 請求的 URL 就是 ?api/heroes?name=cat?。

?HttpParams ?是不可變對象。如果需要更新選項,請保留 ?.set()? 方法的返回值。

你也可以使用 ?fromString ?變量從查詢字符串中直接創(chuàng)建 HTTP 參數:

const params = new HttpParams({fromString: 'name=foo'});

攔截請求和響應

借助攔截機制,你可以聲明一些攔截器,它們可以檢查并轉換從應用中發(fā)給服務器的 HTTP 請求。這些攔截器還可以在返回應用的途中檢查和轉換來自服務器的響應。多個攔截器構成了請求/響應處理器的雙向鏈表。

攔截器可以用一種常規(guī)的、標準的方式對每一次 HTTP 的請求/響應任務執(zhí)行從認證到記日志等很多種隱式任務。

如果沒有攔截機制,那么開發(fā)人員將不得不對每次 ?HttpClient ?調用顯式實現這些任務。

編寫攔截器

要實現攔截器,就要實現一個實現了 ?HttpInterceptor ?接口中的 ?intercept()? 方法的類。

這里是一個什么也不做的 ?noop ?攔截器,它只會不做任何修改的傳遞這個請求。

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

import { Observable } from 'rxjs';

/** Pass untouched request through to the next request handler. */
@Injectable()
export class NoopInterceptor implements HttpInterceptor {

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

?intercept ?方法會把請求轉換成一個最終返回 HTTP 響應體的 ?Observable?。在這個場景中,每個攔截器都完全能自己處理這個請求。

大多數攔截器攔截都會在傳入時檢查請求,然后把(可能被修改過的)請求轉發(fā)給 ?next ?對象的 ?handle()? 方法,而 ?next ?對象實現了 ?HttpHandler ?接口。

export abstract class HttpHandler {
  abstract handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;
}

像 ?intercept()? 一樣,?handle()? 方法也會把 HTTP 請求轉換成 ?HttpEvents ?組成的 ?Observable?,它最終包含的是來自服務器的響應。 ?intercept()? 函數可以檢查這個可觀察對象,并在把它返回給調用者之前修改它。

這個 ?no-op? 攔截器,會使用原始的請求調用 ?next.handle()?,并返回它返回的可觀察對象,而不做任何后續(xù)處理。

next 對象

?next ?對象表示攔截器鏈表中的下一個攔截器。這個鏈表中的最后一個 ?next ?對象就是 ?HttpClient ?的后端處理器(backend handler),它會把請求發(fā)給服務器,并接收服務器的響應。

大多數的攔截器都會調用 ?next.handle()?,以便這個請求流能走到下一個攔截器,并最終傳給后端處理器。 攔截器也可以不調用 ?next.handle()?,使這個鏈路短路,并返回一個帶有人工構造出來的服務器響應的 自己的 ?Observable?。

這是一種常見的中間件模式,在像 Express.js 這樣的框架中也會找到它。

提供這個攔截器

這個 ?NoopInterceptor ?就是一個由 Angular 依賴注入 (DI)系統(tǒng)管理的服務。像其它服務一樣,你也必須先提供這個攔截器類,應用才能使用它。

由于攔截器是 ?HttpClient ?服務的(可選)依賴,所以你必須在提供 ?HttpClient ?的同一個(或其各級父注入器)注入器中提供這些攔截器。那些在 DI 創(chuàng)建完 ?HttpClient ?之后再提供的攔截器將會被忽略。

由于在 ?AppModule ?中導入了 ?HttpClientModule?,導致本應用在其根注入器中提供了 ?HttpClient?。所以你也同樣要在 ?AppModule ?中提供這些攔截器。

在從 ?@angular/common/http? 中導入了 ?HTTP_INTERCEPTORS ?注入令牌之后,編寫如下的 ?NoopInterceptor ?提供者注冊語句:

{ provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },

注意 ?multi: true? 選項。 這個必須的選項會告訴 Angular ?HTTP_INTERCEPTORS ?是一個多重提供者的令牌,表示它會注入一個多值的數組,而不是單一的值。

也可以直接把這個提供者添加到 ?AppModule ?中的提供者數組中,不過那樣會非常啰嗦。況且,你將來還會用這種方式創(chuàng)建更多的攔截器并提供它們。 你還要特別注意提供這些攔截器的順序

認真考慮創(chuàng)建一個封裝桶(barrel)文件,用于把所有攔截器都收集起來,一起提供給 ?httpInterceptorProviders ?數組,可以先從這個 ?NoopInterceptor ?開始。

/* "Barrel" of Http Interceptors */
import { HTTP_INTERCEPTORS } from '@angular/common/http';

import { NoopInterceptor } from './noop-interceptor';

/** Http interceptor providers in outside-in order */
export const httpInterceptorProviders = [
  { provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },
];

然后導入它,并把它加到 ?AppModule ?的 ?providers array? 中,就像這樣:

providers: [
  httpInterceptorProviders
],

當你再創(chuàng)建新的攔截器時,就同樣把它們添加到 ?httpInterceptorProviders ?數組中,而不用再修改 ?AppModule?。

攔截器的順序

Angular 會按你提供攔截器的順序應用它們。比如,考慮一個場景:你想處理 HTTP 請求的身份驗證并記錄它們,然后再將它們發(fā)送到服務器。要完成此任務,你可以提供 ?AuthInterceptor ?服務,然后提供 ?LoggingInterceptor ?服務。發(fā)出的請求將從 ?AuthInterceptor ?到 ?LoggingInterceptor?。這些請求的響應則沿相反的方向流動,從 ?LoggingInterceptor ?回到 ?AuthInterceptor?。以下是該過程的直觀表示:

interceptor-order

該過程中的最后一個攔截器始終是處理與服務器通信的 ?HttpBackend ?服務。

以后你就再也不能修改這些順序或移除某些攔截器了。如果你需要動態(tài)啟用或禁用某個攔截器,那就要在那個攔截器中自行實現這個功能。

處理攔截器事件

大多數 ?HttpClient ?方法都會返回 ?HttpResponse<any>? 型的可觀察對象。?HttpResponse ?類本身就是一個事件,它的類型是 ?HttpEventType.Response?。但是,單個 HTTP 請求可以生成其它類型的多個事件,包括報告上傳和下載進度的事件。?HttpInterceptor.intercept()? 和 ?HttpHandler.handle()? 會返回 ?HttpEvent<any>? 型的可觀察對象。

很多攔截器只關心發(fā)出的請求,而對 ?next.handle()? 返回的事件流不會做任何修改。但是,有些攔截器需要檢查并修改 ?next.handle()? 的響應。上述做法就可以在流中看到所有這些事件。

雖然攔截器有能力改變請求和響應,但 ?HttpRequest ?和 ?HttpResponse ?實例的屬性卻是只讀(?readonly?)的, 因此讓它們基本上是不可變的。

有充足的理由把它們做成不可變對象:應用可能會重試發(fā)送很多次請求之后才能成功,這就意味著這個攔截器鏈表可能會多次重復處理同一個請求。 如果攔截器可以修改原始的請求對象,那么重試階段的操作就會從修改過的請求開始,而不是原始請求。 而這種不可變性,可以確保這些攔截器在每次重試時看到的都是同樣的原始請求。

你的攔截器應該在沒有任何修改的情況下返回每一個事件,除非它有令人信服的理由去做。

TypeScript 會阻止你設置 ?HttpRequest ?的只讀屬性。

// Typescript disallows the following assignment because req.url is readonly
req.url = req.url.replace('http://', 'https://');

如果你必須修改一個請求,先把它克隆一份,修改這個克隆體后再把它傳給 ?next.handle()?。你可以在一步中克隆并修改此請求,例子如下。

// clone request and replace 'http://' with 'https://' at the same time
const secureReq = req.clone({
  url: req.url.replace('http://', 'https://')
});
// send the cloned, "secure" request to the next handler.
return next.handle(secureReq);

這個 ?clone()? 方法的哈希型參數允許你在復制出克隆體的同時改變該請求的某些特定屬性。

修改請求體

?readonly ?這種賦值保護,無法防范深修改(修改子對象的屬性),也不能防范你修改請求體對象中的屬性。

req.body.name = req.body.name.trim(); // bad idea!

如果必須修改請求體,請執(zhí)行以下步驟。

  1. 復制請求體并在副本中進行修改。
  2. 使用 ?clone()? 方法克隆這個請求對象。
  3. 用修改過的副本替換被克隆的請求體。
// copy the body and trim whitespace from the name property
const newBody = { ...body, name: body.name.trim() };
// clone request and set its body
const newReq = req.clone({ body: newBody });
// send the cloned request to the next handler.
return next.handle(newReq);

克隆時清除請求體

有時,你需要清除請求體而不是替換它。為此,請將克隆后的請求體設置為 ?null?。

提示:
如果你把克隆后的請求體設為 ?undefined?,那么 Angular 會認為你想讓請求體保持原樣。
newReq = req.clone({ … }); // body not mentioned => preserve original body
newReq = req.clone({ body: undefined }); // preserve original body
newReq = req.clone({ body: null }); // clear the body

HTTP 攔截器用例

以下是攔截器的一些常見用法。

設置默認請求頭

應用通常會使用攔截器來設置外發(fā)請求的默認請求頭。

該范例應用具有一個 ?AuthService?,它會生成一個認證令牌。在這里,?AuthInterceptor ?會注入該服務以獲取令牌,并對每一個外發(fā)的請求添加一個帶有該令牌的認證頭:

import { AuthService } from '../auth.service';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  constructor(private auth: AuthService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    // Get the auth token from the service.
    const authToken = this.auth.getAuthorizationToken();

    // Clone the request and replace the original headers with
    // cloned headers, updated with the authorization.
    const authReq = req.clone({
      headers: req.headers.set('Authorization', authToken)
    });

    // send cloned request with header to the next handler.
    return next.handle(authReq);
  }
}

這種在克隆請求的同時設置新請求頭的操作太常見了,因此它還有一個快捷方式 ?setHeaders?:

// Clone the request and set the new header in one step.
const authReq = req.clone({ setHeaders: { Authorization: authToken } });

這種可以修改頭的攔截器可以用于很多不同的操作,比如:

  • 認證 / 授權
  • 控制緩存行為。比如 ?If-Modified-Since ?
  • XSRF 防護

記錄請求與響應對

因為攔截器可以同時處理請求和響應,所以它們也可以對整個 HTTP 操作執(zhí)行計時和記錄日志等任務。

考慮下面這個 ?LoggingInterceptor?,它捕獲請求的發(fā)起時間、響應的接收時間,并使用注入的 ?MessageService ?來發(fā)送總共花費的時間。

import { finalize, tap } from 'rxjs/operators';
import { MessageService } from '../message.service';

@Injectable()
export class LoggingInterceptor implements HttpInterceptor {
  constructor(private messenger: MessageService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    const started = Date.now();
    let ok: string;

    // extend server response observable with logging
    return next.handle(req)
      .pipe(
        tap({
          // Succeeds when there is a response; ignore other events
          next: (event) => (ok = event instanceof HttpResponse ? 'succeeded' : ''),
          // Operation failed; error is an HttpErrorResponse
          error: (error) => (ok = 'failed')
        }),
        // Log when response observable either completes or errors
        finalize(() => {
          const elapsed = Date.now() - started;
          const msg = `${req.method} "${req.urlWithParams}"
             ${ok} in ${elapsed} ms.`;
          this.messenger.add(msg);
        })
      );
  }
}

RxJS 的 ?tap ?操作符會捕獲請求成功了還是失敗了。RxJS 的 ?finalize ?操作符無論在響應成功還是失敗時都會調用(這是必須的),然后把結果匯報給 ?MessageService?。

在這個可觀察對象的流中,無論是 ?tap ?還是 ?finalize ?接觸過的值,都會照常發(fā)送給調用者。

自定義 JSON 解析

攔截器可用來以自定義實現替換內置的 JSON 解析。

以下示例中的 ?CustomJsonInterceptor ?演示了如何實現此目的。如果截獲的請求期望一個 ?'json'? 響應,則將 ?responseType ?更改為 ?'text'? 以禁用內置的 JSON 解析。然后,通過注入的 ?JsonParser ?解析響應。

// The JsonParser class acts as a base class for custom parsers and as the DI token.
@Injectable()
export abstract class JsonParser {
  abstract parse(text: string): any;
}

@Injectable()
export class CustomJsonInterceptor implements HttpInterceptor {
  constructor(private jsonParser: JsonParser) {}

  intercept(httpRequest: HttpRequest<any>, next: HttpHandler) {
    if (httpRequest.responseType === 'json') {
      // If the expected response type is JSON then handle it here.
      return this.handleJsonResponse(httpRequest, next);
    } else {
      return next.handle(httpRequest);
    }
  }

  private handleJsonResponse(httpRequest: HttpRequest<any>, next: HttpHandler) {
    // Override the responseType to disable the default JSON parsing.
    httpRequest = httpRequest.clone({responseType: 'text'});
    // Handle the response using the custom parser.
    return next.handle(httpRequest).pipe(map(event => this.parseJsonResponse(event)));
  }

  private parseJsonResponse(event: HttpEvent<any>) {
    if (event instanceof HttpResponse && typeof event.body === 'string') {
      return event.clone({body: this.jsonParser.parse(event.body)});
    } else {
      return event;
    }
  }
}

然后,你可以實現自己的自定義 ?JsonParser?。這是一個具有特殊日期接收器的自定義 JsonParser。

@Injectable()
export class CustomJsonParser implements JsonParser {
  parse(text: string): any {
    return JSON.parse(text, dateReviver);
  }
}

function dateReviver(key: string, value: any) {
  /* . . . */
}

你提供 ?CustomParser ?以及 ?CustomJsonInterceptor?。

{ provide: HTTP_INTERCEPTORS, useClass: CustomJsonInterceptor, multi: true },
{ provide: JsonParser, useClass: CustomJsonParser },

用攔截器實現緩存

攔截器還可以自行處理這些請求,而不用轉發(fā)給 ?next.handle()?。

比如,你可能會想緩存某些請求和響應,以便提升性能。你可以把這種緩存操作委托給某個攔截器,而不破壞你現有的各個數據服務。

下例中的 ?CachingInterceptor ?演示了這種方法。

@Injectable()
export class CachingInterceptor implements HttpInterceptor {
  constructor(private cache: RequestCache) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    // continue if not cacheable.
    if (!isCacheable(req)) { return next.handle(req); }

    const cachedResponse = this.cache.get(req);
    return cachedResponse ?
      of(cachedResponse) : sendRequest(req, next, this.cache);
  }
}
  • ?isCacheable()? 函數用于決定該請求是否允許緩存。在這個例子中,只有發(fā)到 npm 包搜索 API 的 GET 請求才是可以緩存的。
  • 如果該請求是不可緩存的,該攔截器會把該請求轉發(fā)給鏈表中的下一個處理器
  • 如果可緩存的請求在緩存中找到了,該攔截器就會通過 ?of()? 函數返回一個已緩存的響應體的可觀察對象,然后繞過 ?next ?處理器(以及所有其它下游攔截器)
  • 如果可緩存的請求不在緩存中,代碼會調用 ?sendRequest()?。這個函數會把請求轉發(fā)給 ?next.handle()?,它會最終調用服務器并返回來自服務器的響應對象。
/**
 * Get server response observable by sending request to `next()`.
 * Will add the response to the cache on the way out.
 */
function sendRequest(
  req: HttpRequest<any>,
  next: HttpHandler,
  cache: RequestCache): Observable<HttpEvent<any>> {
  return next.handle(req).pipe(
    tap(event => {
      // There may be other events besides the response.
      if (event instanceof HttpResponse) {
        cache.put(req, event); // Update the cache.
      }
    })
  );
}
注意 ?sendRequest()? 是如何在返回應用程序的過程中攔截響應的。該方法通過 ?tap()? 操作符來管理響應對象,該操作符的回調函數會把該響應對象添加到緩存中。
然后,原始的響應會通過這些攔截器鏈,原封不動的回到服務器的調用者那里。
數據服務,比如 ?PackageSearchService?,并不知道它們收到的某些 ?HttpClient ?請求實際上是從緩存的請求中返回來的。

用攔截器來請求多個值

?HttpClient.get()? 方法通常會返回一個可觀察對象,它會發(fā)出一個值(數據或錯誤)。攔截器可以把它改成一個可以發(fā)出多個值的可觀察對象。

修改后的 ?CachingInterceptor ?版本可以返回一個立即發(fā)出所緩存響應的可觀察對象,然后把請求發(fā)送到 NPM 的 Web API,然后把修改過的搜索結果重新發(fā)出一次。

// cache-then-refresh
if (req.headers.get('x-refresh')) {
  const results$ = sendRequest(req, next, this.cache);
  return cachedResponse ?
    results$.pipe( startWith(cachedResponse) ) :
    results$;
}
// cache-or-fetch
return cachedResponse ?
  of(cachedResponse) : sendRequest(req, next, this.cache);
cache-then-refresh 選項是由一個自定義的 ?x-refresh? 請求頭觸發(fā)的。
?PackageSearchComponent ?中的一個檢查框會切換 ?withRefresh ?標識,它是 ?PackageSearchService.search()? 的參數之一。?search()? 方法創(chuàng)建了自定義的 ?x-refresh? 頭,并在調用 ?HttpClient.get()? 前把它添加到請求里。

修改后的 ?CachingInterceptor ?會發(fā)起一個服務器請求,而不管有沒有緩存的值。 就像前面的 ?sendRequest()? 方法一樣進行訂閱。 在訂閱 ?results$? 可觀察對象時,就會發(fā)起這個請求。

  • 如果沒有緩存值,攔截器直接返回 ?results$?。
  • 如果有緩存的值,這些代碼就會把緩存的響應加入到 ?result$? 的管道中,使用重組后的可觀察對象進行處理,并發(fā)出兩次。先立即發(fā)出一次緩存的響應體,然后發(fā)出來自服務器的響應。訂閱者將會看到一個包含這兩個響應的序列。

跟蹤和顯示請求進度

應用程序有時會傳輸大量數據,而這些傳輸可能要花很長時間。文件上傳就是典型的例子。你可以通過提供關于此類傳輸的進度反饋,為用戶提供更好的體驗。

要想發(fā)出一個帶有進度事件的請求,你可以創(chuàng)建一個 ?HttpRequest ?實例,并把 ?reportProgress ?選項設置為 true 來啟用對進度事件的跟蹤。

const req = new HttpRequest('POST', '/upload/file', file, {
  reportProgress: true
});
提示:
每個進度事件都會觸發(fā)變更檢測,所以只有當需要在 UI 上報告進度時,你才應該開啟它們。
當 ?HttpClient.request()? 和 HTTP 方法一起使用時,可以用 ?observe: 'events'? 來查看所有事件,包括傳輸的進度。

接下來,把這個請求對象傳給 ?HttpClient.request()? 方法,該方法返回一個 ?HttpEvents ?的 ?Observable?。

// The `HttpClient.request` API produces a raw event stream
// which includes start (sent), progress, and response events.
return this.http.request(req).pipe(
  map(event => this.getEventMessage(event, file)),
  tap(message => this.showProgress(message)),
  last(), // return last (completed) message to caller
  catchError(this.handleError(file))
);

?getEventMessage ?方法解釋了事件流中每種類型的 ?HttpEvent?。

/** Return distinct message for sent, upload progress, & response events */
private getEventMessage(event: HttpEvent<any>, file: File) {
  switch (event.type) {
    case HttpEventType.Sent:
      return `Uploading file "${file.name}" of size ${file.size}.`;

    case HttpEventType.UploadProgress:
      // Compute and show the % done:
      const percentDone = event.total ? Math.round(100 * event.loaded / event.total) : 0;
      return `File "${file.name}" is ${percentDone}% uploaded.`;

    case HttpEventType.Response:
      return `File "${file.name}" was completely uploaded!`;

    default:
      return `File "${file.name}" surprising upload event: ${event.type}.`;
  }
}

本指南中的范例應用中沒有用來接受上傳文件的服務器。?app/http-interceptors/upload-interceptor.ts? 的 ?UploadInterceptor ?通過返回一個模擬這些事件的可觀察對象來攔截和短路上傳請求。

通過防抖來優(yōu)化與服務器的交互

如果你需要發(fā)一個 HTTP 請求來響應用戶的輸入,那么每次按鍵就發(fā)送一個請求的效率顯然不高。最好等用戶停止輸入后再發(fā)送請求。這種技術叫做防抖。

考慮下面這個模板,它讓用戶輸入一個搜索詞來按名字查找 npm 包。當用戶在搜索框中輸入名字時,?PackageSearchComponent ?就會把這個根據名字搜索包的請求發(fā)給 npm web API。

<input type="text" (keyup)="search(getValue($event))" id="name" placeholder="Search"/>

<ul>
  <li *ngFor="let package of packages$ | async">
    <b>{{package.name}} v.{{package.version}}</b> -
    <i>{{package.description}}</i>
  </li>
</ul>

在這里,?keyup ?事件綁定會將每個按鍵都發(fā)送到組件的 ?search()? 方法。

?$event.target? 的類型在模板中只是 ?EventTarget?,而在 ?getValue()? 方法中,目標會轉換成 ?HTMLInputElement ?類型,以允許對它的 ?value ?屬性進行類型安全的訪問。

getValue(event: Event): string {
  return (event.target as HTMLInputElement).value;
}

這里,?keyup ?事件綁定會把每次按鍵都發(fā)送給組件的 ?search()? 方法。下面的代碼片段使用 RxJS 的操作符為這個輸入實現了防抖。

withRefresh = false;
packages$!: Observable<NpmPackageInfo[]>;
private searchText$ = new Subject<string>();

search(packageName: string) {
  this.searchText$.next(packageName);
}

ngOnInit() {
  this.packages$ = this.searchText$.pipe(
    debounceTime(500),
    distinctUntilChanged(),
    switchMap(packageName =>
      this.searchService.search(packageName, this.withRefresh))
  );
}

constructor(private searchService: PackageSearchService) { }

?searchText$? 是來自用戶的搜索框值的序列。它被定義為 RxJS ?Subject ?類型,這意味著它是一個多播 ?Observable?,它還可以通過調用 ?next(value)? 來自行發(fā)出值,就像在 ?search()? 方法中一樣。

除了把每個 ?searchText ?的值都直接轉發(fā)給 ?PackageSearchService ?之外,?ngOnInit()? 中的代碼還通過下列三個操作符對這些搜索值進行管道處理,以便只有當它是一個新值并且用戶已經停止輸入時,要搜索的值才會抵達該服務。

RXJS 操作符

詳情

debounceTime(500)?

等待用戶停止輸入(本例中為 1/2 秒)。

distinctUntilChanged()

等待搜索文本發(fā)生變化。

switchMap()?

將搜索請求發(fā)送到服務。

這些代碼把 ?packages$? 設置成了使用搜索結果組合出的 ?Observable ?對象。模板中使用 ?AsyncPipe ?訂閱了 ?packages$?,一旦搜索結果的值發(fā)回來了,就顯示這些搜索結果。

使用 switchMap() 操作符

?switchMap()? 操作符接受一個返回 ?Observable ?的函數型參數。在這個例子中,?PackageSearchService.search? 像其它數據服務方法那樣返回一個 ?Observable?。如果先前的搜索請求仍在進行中(如網絡連接不良),它將取消該請求并發(fā)送新的請求。

注意:
?switchMap()? 會按照原始的請求順序返回這些服務的響應,而不用關心服務器實際上是以亂序返回的它們。

如果你覺得將來會復用這些防抖邏輯,可以把它移到單獨的工具函數中,或者移到 ?PackageSearchService ?中。

安全:XSRF 防護

跨站請求偽造 (XSRF 或 CSRF)是一個攻擊技術,它能讓攻擊者假冒一個已認證的用戶在你的網站上執(zhí)行未知的操作。?HttpClient ?支持一種通用的機制來防范 XSRF 攻擊。當執(zhí)行 HTTP 請求時,一個攔截器會從 cookie 中讀取 XSRF 令牌(默認名字為 ?XSRF-TOKEN?),并且把它設置為一個 HTTP 頭 ?X-XSRF-TOKEN?,由于只有運行在你自己的域名下的代碼才能讀取這個 cookie,因此后端可以確認這個 HTTP 請求真的來自你的客戶端應用,而不是攻擊者。

默認情況下,攔截器會在所有的修改型請求中(比如 POST 等)把這個請求頭發(fā)送給使用相對 URL 的請求。但不會在 GET/HEAD 請求中發(fā)送,也不會發(fā)送給使用絕對 URL 的請求。

要獲得這種優(yōu)點,你的服務器需要在頁面加載或首個 GET 請求中把一個名叫 ?XSRF-TOKEN? 的令牌寫入可被 JavaScript 讀到的會話 cookie 中。而在后續(xù)的請求中,服務器可以驗證這個 cookie 是否與 HTTP 頭 ?X-XSRF-TOKEN? 的值一致,以確保只有運行在你自己域名下的代碼才能發(fā)起這個請求。這個令牌必須對每個用戶都是唯一的,并且必須能被服務器驗證,因此不能由客戶端自己生成令牌。把這個令牌設置為你的站點認證信息并且加了鹽(salt)的摘要,以提升安全性。

為了防止多個 Angular 應用共享同一個域名或子域時出現沖突,要給每個應用分配一個唯一的 cookie 名稱。

?HttpClient ?支持的只是 XSRF 防護方案的客戶端這一半。 你的后端服務必須配置為給頁面設置 cookie,并且要驗證請求頭,以確保全都是合法的請求。如果不這么做,就會導致 Angular 的默認防護措施失效。

配置自定義 cookie/header 名稱

如果你的后端服務中對 XSRF 令牌的 cookie 或 頭使用了不一樣的名字,就要使用 ?HttpClientXsrfModule.withConfig()? 來覆蓋掉默認值。

imports: [
  HttpClientModule,
  HttpClientXsrfModule.withOptions({
    cookieName: 'My-Xsrf-Cookie',
    headerName: 'My-Xsrf-Header',
  }),
],

測試 HTTP 請求

如同所有的外部依賴一樣,你必須把 HTTP 后端也 Mock 掉,以便你的測試可以模擬這種與后端的互動。?@angular/common/http/testing? 庫能讓這種 Mock 工作變得直截了當。

Angular 的 HTTP 測試庫是專為其中的測試模式而設計的。在這種模式下,會首先在應用中執(zhí)行代碼并發(fā)起請求。然后,這個測試會期待發(fā)起或未發(fā)起過某個請求,并針對這些請求進行斷言,最終對每個所預期的請求進行刷新(flush)來對這些請求提供響應。

最終,測試可能會驗證這個應用不曾發(fā)起過非預期的請求。

你可以到在線編程環(huán)境中運行這些范例測試 / 下載范例。
本章所講的這些測試位于 ?src/testing/http-client.spec.ts? 中。在 ?src/app/heroes/heroes.service.spec.ts? 中還有一些測試,用于測試那些調用了 ?HttpClient ?的數據服務。

搭建測試環(huán)境

要開始測試那些通過 ?HttpClient ?發(fā)起的請求,就要導入 ?HttpClientTestingModule ?模塊,并把它加到你的 ?TestBed ?設置里去,代碼如下。

// Http testing module and mocking controller
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

// Other imports
import { TestBed } from '@angular/core/testing';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';

然后把 ?HTTPClientTestingModule ?添加到 ?TestBed ?中,并繼續(xù)設置被測服務

describe('HttpClient testing', () => {
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ HttpClientTestingModule ]
    });

    // Inject the http service and test controller for each test
    httpClient = TestBed.inject(HttpClient);
    httpTestingController = TestBed.inject(HttpTestingController);
  });
  /// Tests begin ///
});

現在,在測試中發(fā)起的這些請求會發(fā)給這些測試用的后端(testing backend),而不是標準的后端。

這種設置還會調用 ?TestBed.inject()?,來獲取注入的 ?HttpClient ?服務和模擬對象的控制器 ?HttpTestingController?,以便在測試期間引用它們。

期待并回復請求

現在,你就可以編寫測試,等待 GET 請求并給出模擬響應。

it('can test HttpClient.get', () => {
  const testData: Data = {name: 'Test Data'};

  // Make an HTTP GET request
  httpClient.get<Data>(testUrl)
    .subscribe(data =>
      // When observable resolves, result should match test data
      expect(data).toEqual(testData)
    );

  // The following `expectOne()` will match the request's URL.
  // If no requests or multiple requests matched that URL
  // `expectOne()` would throw.
  const req = httpTestingController.expectOne('/data');

  // Assert that the request is a GET.
  expect(req.request.method).toEqual('GET');

  // Respond with mock data, causing Observable to resolve.
  // Subscribe callback asserts that correct data was returned.
  req.flush(testData);

  // Finally, assert that there are no outstanding requests.
  httpTestingController.verify();
});

最后一步,驗證沒有發(fā)起過預期之外的請求,足夠通用,因此你可以把它移到 ?afterEach()? 中:

afterEach(() => {
  // After every test, assert that there are no more pending requests.
  httpTestingController.verify();
});

自定義對請求的預期

如果僅根據 URL 匹配還不夠,你還可以自行實現匹配函數。比如,你可以驗證外發(fā)的請求是否帶有某個認證頭:

// Expect one request with an authorization header
const req = httpTestingController.expectOne(
  request => request.headers.has('Authorization')
);

像前面的 ?expectOne()? 測試一樣,如果零或兩個以上的請求滿足了這個斷言,它就會拋出異常。

處理一個以上的請求

如果你需要在測試中對重復的請求進行響應,可以使用 ?match()? API 來代替 ?expectOne()?,它的參數不變,但會返回一個與這些請求相匹配的數組。一旦返回,這些請求就會從將來要匹配的列表中移除,你要自己驗證和刷新(flush)它。

// get all pending requests that match the given URL
const requests = httpTestingController.match(testUrl);
expect(requests.length).toEqual(3);

// Respond to each request with different results
requests[0].flush([]);
requests[1].flush([testData[0]]);
requests[2].flush(testData);

測試對錯誤的預期

你還要測試應用對于 HTTP 請求失敗時的防護。

調用 ?request.flush()? 并傳入一個錯誤信息,如下所示。

it('can test for 404 error', () => {
  const emsg = 'deliberate 404 error';

  httpClient.get<Data[]>(testUrl).subscribe({
    next: () => fail('should have failed with the 404 error'),
    error: (error: HttpErrorResponse) => {
      expect(error.status).withContext('status').toEqual(404);
      expect(error.error).withContext('message').toEqual(emsg);
    },
  });

  const req = httpTestingController.expectOne(testUrl);

  // Respond with mock error
  req.flush(emsg, { status: 404, statusText: 'Not Found' });
});

另外,還可以用 ?ProgressEvent ?來調用 ?request.error()?。

it('can test for network error', done => {
  // Create mock ProgressEvent with type `error`, raised when something goes wrong
  // at network level. e.g. Connection timeout, DNS error, offline, etc.
  const mockError = new ProgressEvent('error');

  httpClient.get<Data[]>(testUrl).subscribe({
    next: () => fail('should have failed with the network error'),
    error: (error: HttpErrorResponse) => {
      expect(error.error).toBe(mockError);
      done();
    },
  });

  const req = httpTestingController.expectOne(testUrl);

  // Respond with mock error
  req.error(mockError);
});

將元數據傳遞給攔截器

許多攔截器都需要進行配置或從配置中受益??紤]一個重試失敗請求的攔截器。默認情況下,攔截器可能會重試請求三次,但是對于特別容易出錯或敏感的請求,你可能要改寫這個重試次數。

?HttpClient ?請求包含一個上下文,該上下文可以攜帶有關請求的元數據。該上下文可供攔截器讀取或修改,盡管發(fā)送請求時它并不會傳輸到后端服務器。這允許應用程序或其他攔截器使用配置參數來標記這些請求,比如重試請求的次數。

創(chuàng)建上下文令牌

?HttpContextToken ?用于在上下文中存儲和檢索值。你可以用 ?new ?運算符創(chuàng)建上下文令牌,如以下例所示:

export const RETRY_COUNT = new HttpContextToken(() => 3);

?HttpContextToken ?創(chuàng)建期間傳遞的 lambda 函數 ?() => 3? 有兩個用途:

  1. 它允許 TypeScript 推斷此令牌的類型:?HttpContextToken<number>?。這個請求上下文是類型安全的 —— 從請求上下文中讀取令牌將返回適當類型的值。
  2. 它會設置令牌的默認值。如果尚未為此令牌設置其他值,那么這就是請求上下文返回的值。使用默認值可以避免檢查是否已設置了特定值。

在發(fā)起請求時設置上下文值

發(fā)出請求時,你可以提供一個 ?HttpContext ?實例,在該實例中你已經設置了一些上下文值。

this.httpClient
    .get('/data/feed', {
      context: new HttpContext().set(RETRY_COUNT, 5),
    })
    .subscribe(results => {/* ... */});

在攔截器中讀取上下文值

?HttpContext.get()? 在給定請求的上下文中讀取令牌的值。如果尚未顯式設置令牌的值,則 Angular 將返回令牌中指定的默認值。

import {retry} from 'rxjs';

export class RetryInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const retryCount = req.context.get(RETRY_COUNT);

    return next.handle(req).pipe(
        // Retry the request a configurable number of times.
        retry(retryCount),
    );
  }
}

上下文是可變的(Mutable)

與 ?HttpRequest ?實例的大多數其他方面不同,請求上下文是可變的,并且在請求的其他不可變轉換過程中仍然存在。這允許攔截器通過此上下文協調來操作。比如,?RetryInterceptor ?示例可以使用第二個上下文令牌來跟蹤在執(zhí)行給定請求期間發(fā)生過多少錯誤:

import {retry, tap} from 'rxjs/operators';
export const RETRY_COUNT = new HttpContextToken(() => 3);
export const ERROR_COUNT = new HttpContextToken(() => 0);

export class RetryInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const retryCount = req.context.get(RETRY_COUNT);

    return next.handle(req).pipe(
        tap({
              // An error has occurred, so increment this request's ERROR_COUNT.
             error: () => req.context.set(ERROR_COUNT, req.context.get(ERROR_COUNT) + 1)
            }),
        // Retry the request a configurable number of times.
        retry(retryCount),
    );
  }
}


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號