[NG] 考古 - HttpInterceptor 迴圈引用錯誤

ShiroImo發表於2018-08-26

原文首發於 baishusama.github.io,歡迎圍觀~

前言

恍然間發現這個錯誤已經不復存在了,於是稍微看了下相關 issue、commit、PR。寫篇筆記祭奠下~

需求描述

一個使用 HttpInterceptor 的常見場景是實現基於 token 的驗證機制。

為什麼要使用攔截(intercepting)呢?

因為,在基於 token 的驗證機制中,證明使用者身份的 token 需要被附帶在每一個(需要驗證的請求的)請求頭。如果不使用攔截手段,那麼(由 HttpClient 例項觸發的)每一個請求都需要手動修改請求頭(header)。顯然手動修改是繁瑣和難以維護的。所以,我們選擇做攔截。

Angular 官網也給出了範例,以下程式碼可以實現一個 AuthInterceptor 攔截器:

import { Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { AuthService } from '../auth.service';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  constructor(private auth: AuthService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const authToken = this.auth.getAuthorizationToken();

    const authReq = req.clone({
      headers: req.headers.set('Authorization', authToken)
    });

    return next.handle(authReq);
  }
}
複製程式碼

問題描述

但在 5.2.3 之前,執行上述官方給出的程式碼是會報錯的。原因是 存在迴圈引用問題

依賴關係1

我們看一下上述程式碼:AuthInterceptor 由於需要使用 AuthService 服務提供的獲取 token 的方法,依賴注入了 AuthService

AuthInterceptor -> AuthService  // AuthInterceptor 攔截器需要 AuthService 服務來獲取 token
複製程式碼

依賴關係2

而一般情況下我們的 AuthService 需要做登入登出等操作,特別是需要和後端互動以獲取 token,所以需要依賴注入 HttpClient,存在依賴關係:

AuthService -> HttpClient // AuthService 服務需要 HttpClient 服務來和後端互動
複製程式碼

依賴關係3

從下述原始碼可以看出,HttpClient 服務依賴注入了 HttpHandler

// v5.2.x
export class HttpClient {
  constructor(private handler: HttpHandler) {}

  request(...): Observable<any> {
    let req: HttpRequest<any>;
    ...
    // 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).
    const events$: Observable<HttpEvent<any>> =
        concatMap.call(of (req), (req: HttpRequest<any>) => this.handler.handle(req));
    ...
}
複製程式碼

HttpHandler 的依賴中包含可選的 new Inject(HTTP_INTERCEPTORS)

// v5.2.2
@NgModule({
  imports: [...],
  providers: [
    HttpClient,
    // HttpHandler is the backend + interceptors and is constructed
    // using the interceptingHandler factory function.
    {
      provide: HttpHandler,
      useFactory: interceptingHandler,
      deps: [HttpBackend, [new Optional(), new Inject(HTTP_INTERCEPTORS)]],
    },
    HttpXhrBackend,
    {provide: HttpBackend, useExisting: HttpXhrBackend},
    ...
  ],
})
export class HttpClientModule {
}
複製程式碼

其中,HTTP_INTERCEPTORS 是一個 InjectionToken 例項,用於標識所有攔截器服務。new Inject(HTTP_INTERCEPTORS) 可以獲取攔截器服務的例項們。

這裡的“token”是 Angular 的 DI 系統中用於標識以來物件的東西。token 可以是字串或者 Type/InjectionToken/OpaqueToken 類的例項。

P.S. 關於使用哪一種 token 更好的問題,可以【TODO:】看一下這篇文章譯文)。

也就是說,HttpClient 依賴於所有 HttpInterceptors,包括 AuthInterceptor

HttpClient -> AuthInterceptor // HttpClient 服務需要 AuthInterceptor 在內的所有攔截器服務來處理請求
複製程式碼

迴圈依賴

綜上,我們有迴圈依賴:

AuthInterceptor -> AuthService -> HttpClient -> AuthInterceptor -> ...
複製程式碼

而在 Angular 裡,每一個服務例項的初始化所需要的依賴都是需要事先準備好的,但一個迴圈依賴是永遠也準備不好的……Angular 因此會檢測迴圈依賴的存在,並在迴圈依賴被檢測到時報錯,部分原始碼如下:

// v5.2.x
export class NgModuleProviderAnalyzer {
  private _transformedProviders = new Map<any, ProviderAst>();
  private _seenProviders = new Map<any, boolean>();
  private _allProviders: Map<any, ProviderAst>;
  private _errors: ProviderError[] = [];

  ...

  private _getOrCreateLocalProvider(token: CompileTokenMetadata, eager: boolean): ProviderAst|null {
    const resolvedProvider = this._allProviders.get(tokenReference(token));
    if (!resolvedProvider) {
      return null;
    }
    let transformedProviderAst = this._transformedProviders.get(tokenReference(token));
    if (transformedProviderAst) {
      return transformedProviderAst;
    }
    if (this._seenProviders.get(tokenReference(token)) != null) {
      this._errors.push(
        new ProviderError(`Cannot instantiate cyclic dependency! ${tokenName(token)}`, resolvedProvider.sourceSpan));
      return null;
    }
    this._seenProviders.set(tokenReference(token), true);
    ...
  }
}
複製程式碼

讓我們稍微看一下程式碼:

  • NgModuleProviderAnalyzer 內部通過 Map 型別的 _seenProviders 來記錄看到過的供應商。
  • 在其方法 _getOrCreateLocalProvider 內部判斷是否已經看過,如果已經看過會在 _errors 中記錄一個 ProviderError 錯誤。

我用 5.2.2 版本的 Angular 編寫了一個遵循官方文件寫法但出現“迴圈引用錯誤”的示例專案。下面是我 ng serve 執行該應用後,在 compiler.js 中新增斷點除錯得到的結果:

  • 圖一、截圖時 _seenProviders 中已經記錄的各個供應商:
    _seenProviders
  • 圖二、截圖時 token 變數的值:
    token

在上述截圖中,根據圖二的 token 變數是能在 _seenProviders 中獲取到非 null 值的,所以會向 _errors 中記錄一個 Cannot instantiate cyclic dependency! 開頭的錯誤。當執行完所有程式碼之後,控制檯會出現該錯誤:

interceptor 迴圈引用報錯

使用者的修復

那麼在 5.2.2 及以前,作為 Angular 開發者,要如何解決上述問題呢?

我們可以通過注入 Injector 手動懶載入 AuthService 而不是直接注入其到 constructor,來使依賴關係變為如下:

AuthInterceptor --x-> AuthService -> HttpClient -> AuthInterceptor --x->
即 AuthService -> HttpClient -> AuthInterceptor,其中,在 AuthInterceptor 中懶載入 AuthService
複製程式碼

即將官方的示例程式碼修改為如下:

import { Injectable, Injector } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { AuthService } from '../auth.service';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  private auth: AuthService;

  constructor(private injector: Injector) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    this.auth = this.injector.get(AuthService);

    const authToken = this.auth.getAuthorizationToken();

    const authReq = req.clone({
      headers: req.headers.set('Authorization', authToken)
    });

    return next.handle(authReq);
  }
}
複製程式碼

可以看到和官方的程式碼相比,我們改為依賴注入 Injector,並通過其例項物件 this.injector 在呼叫 intercept 方法時才去獲取 auth 服務例項,而不是將 auth 作為依賴注入、在呼叫建構函式的時候去獲取。

由此我們繞開了編譯階段的對迴圈依賴做的檢查。

官方的修復

就像 PR 裡提到的這樣:

Either HttpClient or the user has to deal specially with the circular dependency.

所以,為了造福大眾,最終官方做出了修改,原理和作為使用者的我們的程式碼的思路是一致的——利用懶載入解決迴圈依賴問題!

因為修復的程式碼量很少,所以這裡整個摘錄下。

首先,新增 HttpInterceptingHandler 類(程式碼一):

// v5.2.3
/**
 * An `HttpHandler` that applies a bunch of `HttpInterceptor`s
 * to a request before passing it to the given `HttpBackend`.
 *
 * The interceptors are loaded lazily from the injector, to allow
 * interceptors to themselves inject classes depending indirectly
 * on `HttpInterceptingHandler` itself.
 */
@Injectable()
export class HttpInterceptingHandler implements HttpHandler {
  private chain: HttpHandler|null = null;

  constructor(private backend: HttpBackend, private injector: Injector) {}

  handle(req: HttpRequest<any>): Observable<HttpEvent<any>> {
    if (this.chain === null) {
      const interceptors = this.injector.get(HTTP_INTERCEPTORS, []);
      this.chain = interceptors.reduceRight(
          (next, interceptor) => new HttpInterceptorHandler(next, interceptor), this.backend);
    }
    return this.chain.handle(req);
  }
}
複製程式碼

HttpHandler 依賴的建立方式由原來的使用 useFactory: interceptingHandler 函式(程式碼二):

// v5.2.2
@NgModule({
  imports: [...],
  providers: [
    HttpClient,
    // HttpHandler is the backend + interceptors and is constructed
    // using the interceptingHandler factory function.
    {
      provide: HttpHandler,
      useFactory: interceptingHandler,
      deps: [HttpBackend, [new Optional(), new Inject(HTTP_INTERCEPTORS)]],
    },
    HttpXhrBackend,
    {provide: HttpBackend, useExisting: HttpXhrBackend},
    ...
  ],
})
export class HttpClientModule {
}
複製程式碼

改為使用 useClass: HttpInterceptingHandler 類(程式碼三):

// v5.2.3
@NgModule({
  imports: [...],
  providers: [
    HttpClient,
    {provide: HttpHandler, useClass: HttpInterceptingHandler},
    HttpXhrBackend,
    {provide: HttpBackend, useExisting: HttpXhrBackend},
    ...
  ],
})
export class HttpClientModule {
}
複製程式碼

不難發現,在“程式碼一”中我們看到了熟悉的寫法:依賴注入 Injector,並通過其例項物件 this.injector 在呼叫 handle 方法時才去獲取 HTTP_INTERCEPTORS 攔截器依賴,而不是將 interceptors 作為依賴注入(在呼叫建構函式的時候去獲取)。

也就是官方修復的思路如下:

AuthInterceptor -> AuthService -> HttpClient -x-> AuthInterceptor
即 AuthInterceptor -> AuthService -> HttpClient,其中,在 HttpClient 中懶載入 interceptors
複製程式碼

因為 AuthInterceptorAuthService 的引用和 AuthServiceHttpClient 的引用是使用者定義的,所以官方可以控制的只剩下 HttpClient 到攔截器的依賴引用了。所以,官方選擇從 HttpClient 處切斷依賴。

那麼,我們為什麼選擇從 AuthInterceptor 處而不是從 AuthService 處切斷依賴呢?

我覺得原因有二:

  1. 一個是為了讓 AuthService 儘可能保持透明——對 interceptor 引起的問題沒有察覺。因為本質上這是 interceptors 不能依賴注入 HttpClient 的問題。
  2. 另一個是 AuthService 往往有很多能觸發 HttpClient 使用的方法,那麼在什麼時候去通過 injector 來 get HttpClient 服務例項呢?或者說所有方法都加上相關判斷麼?……所以為了避免問題的複雜化,選擇選項更少(只有一個 intercept 方法)的 AuthInterceptor 顯然更為明智。

後記

還是太年輕,以前翻 github 的時候沒有及時訂閱 issue,導致一些問題修復了都毫無察覺……

從今天起,好好訂閱 issue,好好整理筆記,共勉~

P.S. 好久沒寫文章了,這篇文章簡直在划水……所以我肯定很多地方沒講清楚(特別是程式碼都沒有細講),各位看官哪裡沒看明白的請務必指出,我會根據需要慢慢補充。望輕拍磚(逃

參考

相關文章