Angular 中攔截器的真相和 HttpClient 內部機制

Ice Panpan發表於2018-12-20

原文:Insider’s guide into interceptors and HttpClient mechanics in Angular
作者:Max Koretskyi
原技術博文由 Max Koretskyi 撰寫釋出,他目前於 ag-Grid 擔任開發大使(Developer Advocate)
譯者按:開發大使負責確保其所在的公司認真聽取社群的聲音並向社群傳達他們的行動及目標,其作為社群和公司之間的紐帶存在。
譯者:Ice Panpan;校對者:dreamdevil00

Angular 中攔截器的真相和 HttpClient 內部機制

您可能知道 Angular 在4.3版本中新引入了強大的 HttpClient。它的一個主要功能是請求攔截(request interception)—— 宣告位於應用程式和後端之間的攔截器的能力。攔截器的文件寫的很好,展示瞭如何編寫並註冊一個攔截器。在這篇文章中,我將深入研究 HttpClient 服務的內部機制,特別是攔截器。我相信這些知識對於深入使用該功能是必要的。閱讀完本文後,您將能夠輕鬆瞭解像快取之類工具的工作流程,並能夠輕鬆自如地實現複雜的請求/響應操作方案。

首先,我們將使用文件中描述的方法來註冊兩個攔截器,以便為請求新增自定義的請求頭。然後我們將實現自定義的中介軟體鏈,而不是使用 Angular 定義的機制。最後,我們將瞭解 HttpClient 的請求方法如何構建 HttpEvents 型別的 observable 流並滿足不可變(immutability)性的需求

與我的大部分文章一樣,我們將通過操作例項來學習更多內容。

應用示例

首先,讓我們實現兩個簡單的攔截器,每個攔截器使用文件中描述的方法向傳出的請求新增請求頭。對於每個攔截器,我們宣告一個實現了 intercept 方法的類。在此方法中,我們通過新增 Custom-Header-1Custom-Header-2 的請求頭資訊來修改請求:

@Injectable()
export class I1 implements HttpInterceptor {
    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const modified = req.clone({setHeaders: {'Custom-Header-1': '1'}});
        return next.handle(modified);
    }
}

@Injectable()
export class I2 implements HttpInterceptor {
    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const modified = req.clone({setHeaders: {'Custom-Header-2': '2'}});
        return next.handle(modified);
    }
}
複製程式碼

正如您所看到的,每個攔截器都將下一個處理程式作為第二個引數。我們需要通過呼叫該函式來將控制權傳遞給中介軟體鏈中的下一個攔截器。我們會很快發現呼叫 next.handle 時發生了什麼以及為什麼有的時候你不需要呼叫該函式。此外,如果你一直想知道為什麼需要對請求呼叫 clone() 方法,你很快就會得到答案。

攔截器實現之後,我們需要使用 HTTP_INTERCEPTORS 令牌註冊它們:

@NgModule({
    imports: [BrowserModule, HttpClientModule],
    declarations: [AppComponent],
    providers: [
        {
            provide: HTTP_INTERCEPTORS,
            useClass: I1,
            multi: true
        },
        {
            provide: HTTP_INTERCEPTORS,
            useClass: I2,
            multi: true
        }
    ],
    bootstrap: [AppComponent]
})
export class AppModule {}
複製程式碼

緊接著執行一個簡單的請求來檢查我們自定義的請求頭是否新增成功:

@Component({
    selector: 'my-app',
    template: `
        <div><h3>Response</h3>{{response|async|json}}</div>
        <button (click)="request()">Make request</button>`
    ,
})
export class AppComponent {
    response: Observable<any>;
    constructor(private http: HttpClient) {}

    request() {
        const url = 'https://jsonplaceholder.typicode.com/posts/1';
        this.response = this.http.get(url, {observe: 'response'});
    }
}
複製程式碼

如果我們已經正確完成了所有操作,當我們檢查 Network 選項卡時,我們可以看到我們自定義的請求頭髮送到伺服器:

Angular 中攔截器的真相和 HttpClient 內部機制

這很容易吧。你可以在 stackblitz 上找到這個基礎示例。現在是時候研究更有趣的東西了。

實現自定義的中介軟體鏈

我們的任務是在不使用 HttpClient 提供的方法的情況下手動將攔截器整合到處理請求的邏輯中。同時,我們將構建一個處理程式鏈,就像 Angular 內部完成的一樣。

處理請求

在現代瀏覽器中,AJAX 功能是使用 XmlHttpRequestFetch API 實現的。此外,還有經常使用的會導致與變更檢測相關的意外結果JSONP 技術。Angular 需要一個使用上述方法之一的服務來向伺服器發出請求。這種服務在 HttpClient 文件上被稱為 後端(backend),例如:

In an interceptor, next always represents the next interceptor in the chain, if any, or the final backend if there are no more interceptors

在攔截器中,next 始終表示鏈中的下一個攔截器(如果有的話),如果沒有更多攔截器的話則表示最終後端

在 Angular 提供的 HttpClient 模組中,這種服務有兩種實現方法——使用 XmlHttpRequest API 實現的HttpXhrBackend 和使用 JSONP 技術實現的 JsonpClientBackendHttpClient 中預設使用 HttpXhrBackend

Angular 定義了一個名為 HTTP(request)handler 的抽象概念,負責處理請求。處理請求的中介軟體鏈由 HTTP handlers 組成,這些處理程式將請求傳遞給鏈中的下一個處理程式,直到其中一個處理程式返回一個 observable 流。處理程式的介面由抽象類 HttpHandler 定義:

export abstract class HttpHandler {
    abstract handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;
}
複製程式碼

由於 backend 服務(如 HttpXhrBackend )可以通過 發出網路請求來處理請求,所以它是 HTTP handler 的一個例子。通過和 backend 服務通訊來處理請求是最常見的處理形式,但卻不是唯一的處理方式。另一種常見的請求處理示例是從本地快取中為請求提供服務,而不是傳送請求給伺服器。因此,任何可以處理請求的服務都應該實現 handle 方法,該方法根據函式簽名返回一個 HTTP events 型別的 observable,如 HttpProgressEventHttpHeaderResponseHttpResponse。因此,如果我們想提供一些自定義請求處理邏輯,我們需要建立一個實現了 HttpHandler 介面的服務。

使用 backend 作為 HTTP handler

HttpClient 服務在 DI 容器中的 HttpHandler 令牌下注入了一個全域性的 HTTP handler 。然後通過呼叫它的 handle 方法來發出請求:

export class HttpClient {
    constructor(private handler: HttpHandler) {}
    
    request(...): Observable<any> {
        ...
        const events$: Observable<HttpEvent<any>> = 
            of(req).pipe(concatMap((req: HttpRequest<any>) => this.handler.handle(req)));
        ...
    }
}
複製程式碼

預設情況下,全域性 HTTP handler 是 HttpXhrBackend backend。它被註冊在注入器中的 HttpBackend 令牌下。

@NgModule({
    providers: [
        HttpXhrBackend,
        { provide: HttpBackend, useExisting: HttpXhrBackend } 
    ]
})
export class HttpClientModule {}
複製程式碼

正如你可能猜到的那樣 HttpXhrBackend 實現了 HttpHandler 介面:

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

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

export class HttpXhrBackend implements HttpBackend {
    handle(req: HttpRequest<any>): Observable<HttpEvent<any>> {}
}
複製程式碼

由於預設的 XHR backend 是在 HttpBackend 令牌下注冊的,我們可以自己注入並替換 HttpClient 用於發出請求的用法。我們替換掉下面這個使用 HttpClient 的版本:

export class AppComponent {
    response: Observable<any>;
    constructor(private http: HttpClient) {}

    request() {
        const url = 'https://jsonplaceholder.typicode.com/posts/1';
        this.response = this.http.get(url, {observe: 'body'});
    }
}
複製程式碼

讓我們直接使用預設的 XHR backend,如下所示:

export class AppComponent {
    response: Observable<any>;
    constructor(private backend: HttpXhrBackend) {}

    request() {
        const req = new HttpRequest('GET', 'https://jsonplaceholder.typicode.com/posts/1');
        this.response = this.backend.handle(req);
    }
}
複製程式碼

這是示例。在示例中需要注意一些事項。首先,我們需要手動構建 HttpRequest。其次,由於 backend 處理程式返回 HTTP events 流,你將在螢幕上看到不同的物件一閃而過,最終將呈現整個 http 響應物件。

新增攔截器

我們已經設法直接使用 backend,但由於我們沒有執行攔截器,所以請求頭尚未新增到請求中。一個攔截器包含處理請求的邏輯,但它要與 HttpClient 一起使用,需要將其封裝到實現了 HttpHandler 介面的服務中。我們可以通過執行一個攔截器並將鏈中的下一個處理程式的引用傳遞給此攔截器的方式來實現此服務。這樣攔截器就可以觸發下一個處理程式,後者通常是 backend。為此,每個自定義的處理程式將儲存鏈中下一個處理程式的引用,並將其與請求一起傳遞給下一個攔截器。下面就是我們想要的東西:

Angular 中攔截器的真相和 HttpClient 內部機制

在 Angular 中已經存在這種封裝處理程式的方法了並被稱為 HttpInterceptorHandler。讓我們用它來封裝我們的一個攔截器吧。但是不幸的是,Angular 沒有將其匯出為公共 API,因此我們只能從原始碼中複製基本實現:

export class HttpInterceptorHandler implements HttpHandler {
    constructor(private next: HttpHandler, private interceptor: HttpInterceptor) {}

    handle(req: HttpRequest<any>): Observable<HttpEvent<any>> {
        // execute an interceptor and pass the reference to the next handler
        return this.interceptor.intercept(req, this.next);
    }
}
複製程式碼

並像這樣使用它來封裝我們的第一個攔截器:

export class AppComponent {
    response: Observable<any>;
    constructor(private backend: HttpXhrBackend) {}

    request() {
        const req = new HttpRequest('GET', 'https://jsonplaceholder.typicode.com/posts/1');
        const handler = new HttpInterceptorHandler(this.backend, new I1());
        this.response = handler.handle(req);
    }
}
複製程式碼

現在,一旦我們發出請求,我們就可以看到 Custom-Header-1 已新增到請求中。這是示例。通過上面的實現,我們將一個攔截器和引用了下一個處理程式的 XHR backend 封裝進了 HttpInterceptorHandler。現在,這就是這是一條處理程式鏈。

讓我們通過封裝第二個攔截器來將另一個處理程式新增到鏈中:

export class AppComponent {
    response: Observable<any>;
    constructor(private backend: HttpXhrBackend) {}

    request() {
        const req = new HttpRequest('GET', 'https://jsonplaceholder.typicode.com/posts/1');
        const i1Handler = new HttpInterceptorHandler(this.backend, new I1());
        const i2Handler = new HttpInterceptorHandler(i1Handler, new I2());
        this.response = i2Handler.handle(req);
    }
}
複製程式碼

在這可以看到演示,現在一切正常,就像我們在最開始的示例中使用 HttpClient 的那樣。我們剛剛所做的就是構建了處理程式的中介軟體鏈,其中每個處理程式執行一個攔截器並將下一個處理程式的引用傳遞給它。這是鏈的圖表:

Angular 中攔截器的真相和 HttpClient 內部機制

當我們在攔截器中執行 next.handle(modified) 語句時,我們將控制權傳遞給鏈中的下一個處理程式:

export class I1 implements HttpInterceptor {
    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const modified = req.clone({setHeaders: {'Custom-Header-1': '1'}});
        // passing control to the handler in the chain
        return next.handle(modified);
    }
}
複製程式碼

最終,控制權將被傳遞到最後一個 backend 處理程式,該處理程式將對伺服器執行請求。

自動封裝攔截器

我們可以通過使用 HTTP_INTERCEPTORS 令牌注入所有的攔截器,然後使用 reduceRight 將它們連結起來的方式自動構建攔截器鏈,而不是逐個地手動將攔截器連結起來構成攔截器鏈。我們這樣做:

export class AppComponent {
    response: Observable<any>;
    constructor(
        private backend: HttpBackend, 
        @Inject(HTTP_INTERCEPTORS) private interceptors: HttpInterceptor[]) {}

    request() {
        const req = new HttpRequest('GET', 'https://jsonplaceholder.typicode.com/posts/1');
        const i2Handler = this.interceptors.reduceRight(
            (next, interceptor) => new HttpInterceptorHandler(next, interceptor), this.backend);
        this.response = i2Handler.handle(req);
    }
}
複製程式碼

我們需要在這裡使用 reduceRight 來從最後註冊的攔截器開始構建一個鏈。使用上面的程式碼,我們會獲得與手動構建的處理程式鏈相同的鏈。通過 reduceRight 返回的值是對鏈中第一個處理程式的引用。

實際上,上述我寫的程式碼在 Angular 中是使用 interceptingHandler 函式來實現的。原話是這麼說的:

Constructs an HttpHandler that applies a bunch of HttpInterceptors to a request before passing it to the given HttpBackend. Meant to be used as a factory function within HttpClientModule.

構造一個 HttpHandler,在將請求傳遞給給定的 HttpBackend 之前,將一系列 HttpInterceptor 應用於請求。 可以在 HttpClientModule 中用作工廠函式。

(下面順便貼一下原始碼:)

export function interceptingHandler(
    backend: HttpBackend, interceptors: HttpInterceptor[] | null = []): HttpHandler {
  if (!interceptors) {
    return backend;
  }
  return interceptors.reduceRight(
      (next, interceptor) => new HttpInterceptorHandler(next, interceptor), backend);
}
複製程式碼

現在我們知道是如何構造一條處理函式鏈的了。在 HTTP handler 中需要注意的最後一點是, interceptingHandler 預設為 HttpHandler

@NgModule({
  providers: [
    {
      provide: HttpHandler,
      useFactory: interceptingHandler,
      deps: [HttpBackend, [@Optional(), @Inject(HTTP_INTERCEPTORS)]],
    }
  ]
})
export class HttpClientModule {}
複製程式碼

因此,執行此函式的結果是鏈中第一個處理程式的引用被注入 HttpClient 服務並被使用。

構建處理鏈的 observable 流

好的,現在我們知道我們有一堆處理程式,每個處理程式執行一個關聯的攔截器並呼叫鏈中的下一個處理程式。呼叫此鏈返回的值是一個 HttpEvents 型別的 observable 流。這個流通常(但不總是)由最後一個處理程式生成,這跟 backend 的具體實現有關。其他的處理程式通常只返回該流。下面是大多數攔截器最後的語句:

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    ...
    return next.handle(authReq);
}
複製程式碼

所以我們可以這樣來展示邏輯:

Angular 中攔截器的真相和 HttpClient 內部機制

但是因為任何攔截器都可以返回一個 HttpEvents 型別的 observable 流,所以你有很多定製機會。例如,你可以實現自己的 backend 並將其註冊為攔截器。或者實現一個快取機制,如果找到了快取就立即返回, 而不用交給下個處理程式處理:

Angular 中攔截器的真相和 HttpClient 內部機制

此外,由於每個攔截器都可以訪問下一個攔截器(通過呼叫 next.handler())返回的 observable 流,所以我們可以通過 RxJs 操作符新增自定義的邏輯來修改返回的流。

構建 HttpClient 的 observable 流

如果您仔細閱讀了前面的部分,那麼您現在可能想知道處理鏈建立的 HTTP events 流是否與呼叫 HttpClient 方法,如 get 或者 post 所返回的流完全相同。咦...不是!實現的過程更有意思。

HttpClient 通過使用 RxJS 的建立操作符 of 來將請求物件變為 observable 流,並在呼叫 HttpClient 的 HTTP request 方法時返回它。處理程式鏈作為此流的一部分被同步處理,並且使用 concatMap 操作符壓平鏈返回的 observable實現的關鍵點就在 request 方法,因為所有的 API 方法像 getpostdelete只是包裝了 request 方法:

const events$: Observable<HttpEvent<any>> = of(req).pipe(
    concatMap((req: HttpRequest<any>) => this.handler.handle(req))
);
複製程式碼

在上面的程式碼片段中,我用 pipe 替換了舊技術 call。如果您仍然對 concatMap 如何工作感到困惑,你可以閱讀學習將 RxJs 序列與超級直觀的互動式圖表相結合。有趣的是,處理程式鏈在以 of 開頭的 observable 流中執行是有原因的,這裡有一個解釋:

Start with an Observable.of() the initial request, and run the handler (which includes all interceptors) inside a concatMap(). This way, the handler runs inside an Observable chain, which causes interceptors to be re-run on every subscription (this also makes retries re-run the handler, including interceptors).

通過 Observable.of() 初始請求,並在 concatMap() 中執行處理程式(包括所有攔截器)。這樣,處理程式就在一個 Observable 鏈中執行,這會使得攔截器會在每個訂閱上重新執行(這樣重試的時候也會重新執行處理程式,包括攔截器)。

處理 ‘observe’ 請求選項

通過 HttpClient 建立的初始 observable 流,發出了所有的 HTTP events,如 HttpProgressEventHttpHeaderResponseHttpResponse。但是從文件中我們知道我們可以通過設定 observe 選項來指定我們感興趣的事件:

request() {
    const url = 'https://jsonplaceholder.typicode.com/posts/1';
    this.response = this.http.get(url, {observe: 'body'});
}
複製程式碼

使用 {observe: 'body'} 後,從 get 方法返回的 observable 流只會發出響應中 body 部分的內容。 observe 的其他選項還有 eventsresponse 並且 response 是預設選項。在探索處理程式鏈的實現的一開始,我就指出過呼叫處理程式鏈返回的流會發出所有 HTTP events。根據 observe 的引數過濾這些 events 是 HttpClient 的責任。

這意味著我在上一節中演示 HttpClient 返回流的實現需要稍微調整一下。我們需要做的是過濾這些 events 並根據 observe 引數值將它們對映到不同的值。接下來簡單實現下:


const events$: Observable<HttpEvent<any>> = of(req).pipe(...)

if (options.observe === 'events') {
    return events$;
}

const res$: Observable<HttpResponse<any>> =
    events$.pipe(filter((event: HttpEvent<any>) => event instanceof HttpResponse));

if (options.observe === 'response') {
    return res$;
}

if (options.observe === 'body') {
    return res$.pipe(map((res: HttpResponse<any>) => res.body));
}
複製程式碼

在這裡,您可以找到原始碼。

不可變性的需要

文件上關於不變性的一個有趣的段落是這樣的:

Interceptors exist to examine and mutate outgoing requests and incoming responses. However, it may be surprising to learn that the HttpRequest and HttpResponse classes are largely immutable. This is for a reason: because the app may retry requests, the interceptor chain may process an individual request multiple times. If requests were mutable, a retried request would be different than the original request. Immutability ensures the interceptors see the same request for each try.

雖然攔截器有能力改變請求和響應,但 HttpRequest 和 HttpResponse 例項的屬性卻是隻讀(readonly)的,因此,它們在很大意義上說是不可變物件。有充足的理由把它們做成不可變物件:應用可能會重試傳送很多次請求之後才能成功,這就意味著這個攔截器連結串列可能會多次重複處理同一個請求。 如果攔截器可以修改原始的請求物件,那麼重試階段的操作就會從修改過的請求開始,而不是原始請求。 而這種不可變性,可以確保這些攔截器在每次重試時看到的都是同樣的原始請求。

讓我詳細說明一下。當您呼叫 HttpClient 的任何 HTTP 請求方法時,就會建立請求物件。正如我在前面部分中解釋的那樣,此請求用於生成一個 events$ 的 observable 序列,並且在訂閱時,它會在處理程式鏈中被傳遞。但是 events$ 流可能會被重試,這意味著在序列之外建立的原始請求物件可能再次觸發序列多次。但攔截器應始終以原始請求開始。如果請求是可變的,並且可以在攔截器執行期間進行修改,則此條件不適用於下一次攔截器執行。由於同一請求物件的引用將多次用於開始 observable 序列,請求及其所有組成部分,如 HttpHeadersHttpParams 應該是不可變的。

相關文章