qrcode

马上订阅,开启修仙之旅

Angular HttpClient 拦截器

在之前的 Angular 6 HttpClient 快速入门 文章中,我们已经简单介绍了 Http 拦截器。本文将会进一步分析一下 Http 拦截器。拦截器提供了一种用于拦截、修改请求和响应的机制。这个概念与 Node.js 的 Express 框架中间件的概念类似。拦截器提供的这种特性,对于日志、缓存、请求授权来说非常有用。

AuthInterceptor

接下来我们先来回顾一下 Angular 6 HttpClient 快速入门 这篇文章中所使用的示例:

auth.interceptor.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Injectable } from "@angular/core";
import { HttpEvent, HttpRequest, HttpHandler, HttpInterceptor } from "@angular/common/http";

import { Observable } from "rxjs";

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
intercept(
req: HttpRequest<any>,
next: HttpHandler
): Observable<HttpEvent<any>> {
const clonedRequest = req.clone({
headers: req.headers.set("X-CustomAuthHeader", "iloveangular")
});
console.log("new headers", clonedRequest.headers.keys());
return next.handle(clonedRequest);
}
}

要实现自定义拦截器,首先我需要定义一个类并实现 HttpInterceptor 接口:

1
2
3
export interface HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>>;
}

实现 HttpInterceptor 接口,就需要实现该接口中定义的 intercept(),该方法接收两个参数:

  • req:HttpRequest 对象,即请求对象。
  • next:HttpHandler 对象,该对象有一个 handle() 方法,该方法返回一个 Observable 对象。

在上面的 AuthInterceptor 拦截器中,我们实现的功能就是设置自定义请求头。接下来我们来介绍如何利用拦截器实现请求日志记录的功能。

LoggingInterceptor

下面我们来定义 LoggingInterceptor 拦截器,该拦截器实现的功能是记录每个请求的响应状态和时间。

logging.interceptor.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpResponse } from '@angular/common/http';
import { finalize, tap } from 'rxjs/operators';
import { LoggerService } from '../logger.service';

@Injectable()
export class LoggingInterceptor implements HttpInterceptor {

constructor(private loggerService: LoggerService) {}

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

return next.handle(req).pipe(
tap(
event => {
status = '';
if (event instanceof HttpResponse) {
status = 'succeeded';
}
},
error => status = 'failed'
),
finalize(() => {
const elapsedTime = Date.now() - startTime;
const message = req.method + " " + req.urlWithParams +" "+ status
+ " in " + elapsedTime + "ms";

this.loggerService.log(message);
})
);
}
}

logger.service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Injectable } from "@angular/core";

@Injectable({
providedIn: "root"
})
export class LoggerService {
log(msg: string): void {
console.log(msg);
}

error(msg: string, obj = {}): void {
console.error(msg, obj);
}
}

定义完 LoggingInterceptor 拦截器,在使用它之前还需对它进行配置:

1
2
3
4
5
6
7
8
9
10
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, HttpClientModule],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true }
],
bootstrap: [AppComponent]
})
export class AppModule {}

接着我们来继续更新一下 AppComponent 根组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Component } from "@angular/core";
import { HttpClient } from "@angular/common/http";

@Component({
selector: "app-root",
template: `
<h3>Angular Http Interceptor</h3>
<button (click)="getUsers()">Get Users</button>
`,
styles: [`button {border: 1px solid blue;}`]
})
export class AppComponent {
constructor(public http: HttpClient) {}

getUsers(): void {
this.http
.get("http://jsonplaceholder.typicode.com/users")
.subscribe(res => {
console.dir(res);
});
}
}

然后启动应用,当我们点击 Get Users 按钮时,控制台会输出一下信息:

1
GET http://jsonplaceholder.typicode.com/users succeeded in 728ms

好的,趁热打铁,我们再来一个例子,即介绍如何利用拦截器实现简单的缓存控制。

CachingInterceptor

在实现缓存拦截器之前,我们先来定义一个 Cache 接口:

1
2
3
4
5
6
import { HttpRequest, HttpResponse } from '@angular/common/http';

export interface Cache {
get(req: HttpRequest<any>): HttpResponse<any> | null;
put(req: HttpRequest<any>, res: HttpResponse<any>): void;
}

上面定义的 Cache 接口中,包含两个方法:

  • get(req: HttpRequest): HttpResponse| null —— 用于获取 req 请求对象对应的响应对象;
  • put(req: HttpRequest, res: HttpResponse): void; —— 用于保存 req 对象对应的响应对象。

另外在实际的场景中,我们一般都会为缓存设置一个最大的缓存时间,即缓存的有效期。在有效期内,如果缓存命中,则会直接返回已缓存的响应对象。下面我们再来定义一个 CacheEntry 接口,该接口包含三个属性:

  • url: string —— 被缓存的请求 URL 地址
  • response: HttpResponse—— 被缓存的响应对象
  • entryTime: number —— 响应对象被缓存的时间,用于判断缓存是否过期

此外,我们还要定义一个常量,用于设定缓存的有效期,这里我们假设缓存的时间为 30 s,具体如下:

1
2
3
4
5
6
7
8
9
import { HttpResponse } from "@angular/common/http";

export const MAX_CACHE_AGE = 30000; // 单位为毫秒

export interface CacheEntry {
url: string;
response: HttpResponse<any>;
entryTime: number;
}

定义完 Cache 和 CacheEntry 接口,我们来实现 CacheService 服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { Injectable } from "@angular/core";
import { HttpRequest, HttpResponse } from "@angular/common/http";

import { Cache } from "./cache";
import { CacheEntry, MAX_CACHE_AGE } from "./cache.entry";
import { LoggerService } from './logger.service';

@Injectable({
providedIn: "root"
})
export class CacheService implements Cache {
cacheMap = new Map<string, CacheEntry>();

constructor(private logger: LoggerService) {}

get(req: HttpRequest<any>): HttpResponse<any> | null {
// 判断当前请求是否已被缓存,若未缓存则返回null
const entry = this.cacheMap.get(req.urlWithParams);
if (!entry) return null;
// 若缓存命中,则判断缓存是否过期,若已过期则返回null。否则返回请求对应的响应对象
const isExpired = Date.now() - entry.entryTime > MAX_CACHE_AGE;
this.logger.log(`req.urlWithParams is Expired: ${isExpired} `);
return isExpired ? null : entry.response;
}

put(req: HttpRequest<any>, res: HttpResponse<any>): void {
// 创建CacheEntry对象
const entry: CacheEntry = {
url: req.urlWithParams,
response: res,
entryTime: Date.now()
};
this.logger.log(`Save entry.url response into cache`);
// 以请求url作为键,CacheEntry对象为值,保存到cacheMap中。并执行
// 清理操作,即清理已过期的缓存。
this.cacheMap.set(req.urlWithParams, entry);
this.deleteExpiredCache();
}

private deleteExpiredCache() {
this.cacheMap.forEach(entry => {
if (Date.now() - entry.entryTime > MAX_CACHE_AGE) {
this.cacheMap.delete(entry.url);
}
});
}
}

现在万事俱备只欠 “东风导弹” —— CachingInterceptor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpResponse, HttpHandler } from '@angular/common/http';
import { of } from 'rxjs';
import { tap } from 'rxjs/operators';

import { CacheService } from '../cache.service';

const CACHABLE_URL = "http://jsonplaceholder.typicode.com";

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

intercept(req: HttpRequest<any>, next: HttpHandler) {
// 判断当前请求是否可缓存
if (!this.isRequestCachable(req)) {
return next.handle(req);
}
// 获取请求对应的缓存对象,若存在则直接返回该请求对象对应的缓存对象
const cachedResponse = this.cache.get(req);
if (cachedResponse !== null) {
return of(cachedResponse);
}
// 发送请求至API站点,请求成功后保存至缓存中
return next.handle(req).pipe(
tap(event => {
if (event instanceof HttpResponse) {
this.cache.put(req, event);
}
})
);
}

// 判断当前请求是否可缓存
private isRequestCachable(req: HttpRequest<any>) {
return (req.method === 'GET') && (req.url.indexOf(CACHABLE_URL) > -1);
}
}

与 LoggingInterceptor 拦截器一样,在使用它之前还需对 CachingInterceptor 进行配置:

1
2
3
4
5
6
7
8
9
10
11
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, HttpClientModule],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: CachingInterceptor, multi: true }
],
bootstrap: [AppComponent]
})
export class AppModule {}

当应用启动后,我们点击页面上的 Get Users 按钮,此时控制台会输出以下内容:

1
2
logger.service.ts:8 Save entry.url response into cache
logger.service.ts:8 GET http://jsonplaceholder.typicode.com/users succeeded in 1296ms

然后在过期前,我们再次点击 Get Users 按钮,这时控制台会输出以下内容:

1
2
logger.service.ts:8 req.urlWithParams is Expired: false 
logger.service.ts:8 GET http://jsonplaceholder.typicode.com/users succeeded in 2ms

而等缓存过期后(30 s),我们接着点击 Get Users 按钮,这时控制台会输出以下内容:

1
2
3
req.urlWithParams is Expired: true 
logger.service.ts:8 Save entry.url response into cache
logger.service.ts:8 GET http://jsonplaceholder.typicode.com/users succeeded in 1255ms

通过观察以上的输出内容,我们发现 CachingInterceptor 已经能按照我们的预期正常工作了。此时,我们已经介绍了拦截器三个常见的使用场景,最后我们以 AuthInterceptor 拦截器为例,简单介绍一下如何进行单元测试。

Testing

为了方便演示 AuthInterceptor 拦截器的单元测试,首先我们先来定义一个 UserService 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";

@Injectable()
export class UserService {
ROOT_URL = `http://jsonplaceholder.typicode.com`;

constructor(private http: HttpClient) {}

getUsers() {
return this.http.get(`${this.ROOT_URL}/users`);
}
}

接着再定义一个 spec 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { TestBed } from "@angular/core/testing";
import {
HttpClientTestingModule,
HttpTestingController
} from "@angular/common/http/testing";
import { HTTP_INTERCEPTORS } from "@angular/common/http";

import { AuthInterceptor } from "./interceptors/auth.interceptor";
import { UserService } from "./user.service";

describe(`AuthInterceptor`, () => {
let service: UserService;
let httpMock: HttpTestingController;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
UserService,
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true
}
]
});

service = TestBed.get(UserService);
httpMock = TestBed.get(HttpTestingController);
});

it("should Authorization header is iloveangular", () => {
service.getUsers().subscribe(response => {
expect(response).toBeTruthy();
});

const httpRequest = httpMock.expectOne(
`http://jsonplaceholder.typicode.com/users`
);

expect(httpRequest.request.headers.get("X-CustomAuthHeader")).toBe(
"iloveangular"
);
});
});

在完成 spec 文件的定义之后,我们就可以运行 npm run testng test 命令,运行单元测试了。这里只是简单介绍了如何为 AuthInterceptor 拦截器写单元测试,对于单元测试的同学,建议阅读官方或其他的学习资料。


欢迎小伙伴们订阅前端修仙之路,及时阅读 Angular、RxJS、TypeScript 和 Node.js 最新文章。

qrcode