本文介绍 Angular HTTP 相关知识点。包括如下内容:
- Angular 中如何消耗 REST API.
- 使用 resolver 获取数据。
- 创建请求、响应拦截器。
- 在应用中缓存数据。
- 使用单元测试检测数据服务。
1. Module 1
使用 HttpClientModule 的第一步就是在根模块中注入此模块。
这样做之后,你就可以在任何组件或者自定义服务中使用名为 HttpClient 的服务了。
2. Module 2
2.1 什么是 RESTFUL API
2.2 RESTFUL API 和 CRUD
如下所示,restful api 中的方法和 crud 可以对应起来,对应关系为:
- create -- post
- read -- get
- update -- put
- delete -- delete
2.3 Observable
由于使用 HttpClient 服务提供的方法时,各个方法的返回值都是 Observable 类型的,因此,必须了解如何处理 Observable 类型的数据。而 Observable 也是 Rxjs 或者 Angular 的重要组成部分。
下面是一个典型的示例。展示了如何在自定义服务中使用 HttpClient 示例发送网络请求并处理其返回值。
注意上面的类型约束,特别是 Observable 类型约束。
2.4 设置请求头
在发送网络请求的时候有的时候我们需要通过请求头传递给服务器额外的信息。例如令牌或者期望返回数据的格式等。
2.5 使用操作 Observable 对象的 operators
使用 operators 的优势在于:
- 操作一个 observable 数据并返回一个新的 observable 数据。
- 可以串联起来完成对数据的复杂转化。
- 具有极强的灵活性,可以将数据转换成你想要的任意格式。
下面的这个代码示例是展示 pipe 操作符将 map 和 tap 操作符联合起来使用。其中 map 操作符改变了网络请求返回值的数据结构。而 tap 操作符只是将改变之后的数据结构打印在控制台中,并没有对其进行修改。
2.6 CRUD 示例
3. Module 3 -- 处理错误及路由守卫
3.1 正确的处理网络请求错误
处理网络请求错误的一些原则:
- 将网络请求封装起来。
- 不要将实现细节溢出到具体的组件中去。
- 使用 RxJS 中的 catchError 操作符来捕获错误。
- 将处理之后的错误对象返回给调用者。
下面的例子展示了处理网络请求错误的标准流程:
在这个过程中,我们使用了 RxJS 提供的 pipe catchError throwError
这些 operators.
3.2 Resolvers
什么是 Resolvers? 有什么优势?
- 在发生路由跳转之前获取数据。
- 可以用来阻止"白屏"的出现。
- 可以用来阻止"有内部错误"的组件的渲染。
- 在某些情况下能带来更好的用户体验(如果某一个页面的数据由唯一的数据接口提供的话)。
Route Transitions With Resolvers 图解
下面使用 resolver 实现核心数据加载完成之后再进行路由跳转的功能。
- 第一步明确 resolver 本质上是一个实现了 Resolve 接口的自定义 service,然后完成其基本结构的构建。
- 第二步补充 resolve 接口内容即可。
- 第三步修改路由配置 在对应的路由上添加 resolve 配置:
- 第四步从路由上获取网络数据而不是重新发起网络请求 由于在 resolve 中已经发起请求了,请求的数据会保存在路由信息对象中。因此目标组件无需再次发起请求。
4. Module 4 -- 请求/响应拦截器
首先需要明确的是所谓拦截器,本质上是实现了 HttpInterceptor 接口的自定义服务,又分为请求拦截器和响应拦截器两种。
拦截器的原理如下所示:
拦截器的作用为:
- 给每一个请求统一添加请求头信息。
- 输出网络请求相关的日志。
- 用来检测网络请求的进度。
- 实现客户端的缓存。
4.1 拦截器实现
实现一个简单的拦截器,如下所示:
也许您对 axios 拦截器更加熟悉,如果对比来看,可以发现,Angular 中的拦截器有以下几个特点:
1. 本质是一个服务,需要被 @Injectable
修饰符修饰;实现了 HttpInterceptor 接口的 intercept 方法。
2. 作为请求拦截器,需要将请求对象拷贝一份,因为请求对象是 immutable 不可变对象。
3. 请求拦截器和响应拦截器不是分开的,一个服务既可以拦截请求也可以拦截响应,起作用的代码的位置不同而已。
4. 虽然可以将拦截请求和响应的逻辑写在一个服务中,但是实践中还是推荐将它们分开来写。
4.2 拦截器服务注入方式
拦截器本质上是服务,所以必须先被 providers 才能在组件中使用。
从上面的图中不难发现,网络拦截器不只有一个,它可以有多个,因此我们将 multi 的值设置为 true.
4.3 req.clone 方法
下面我们通过 clone 方法给拦截到的网络请求加上新的请求头。
那么这个拦截器是如何生效的呢?
- 我们将这个服务注入到 sharedModule 中,或者叫做 coreModule 中去。
- 然后在需要的模块中引入 coreModule 即可。
- 注意无需在 component 中手动调用相关。
4.4 使用多个拦截器
使用下面的代码创建一个新的拦截器。
然后和上面的拦截器一起使用。
需要注意的是,这里使用了多个拦截器,所以它们之间肯定是有先后顺序的。
5. Module 5 -- 缓存网络请求
首先,需要明确这么做的意义,也就是为什么要进行网络请求的缓存。
- 减少前端发起请求的次数。
- 减少服务器压力。
- 提高数据响应速度。
制作一个用来缓存网络请求的服务如下:
然后我们创建一个拦截器服务来使用上面的缓存服务:
ts
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpResponse, HttpInterceptor } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { HttpCacheService } from 'app/services/http-cache.service';
@Injectable()
export class CacheInterceptor implements HttpInterceptor {
constructor(private cacheService: HttpCacheService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// pass along non-cacheable requests and invalidate cache
if (req.method !== 'GET') {
console.log(`Invalidating cache: ${req.method} ${req.url}`);
this.cacheService.invalidateCache();
return next.handle(req);
}
const cachedResponse: HttpResponse<any> = this.cacheService.get(req.url);
// return cached response
if (cachedResponse) {
console.log(`Returning a cached response: ${cachedResponse.url}`);
console.log(cachedResponse);
return of(cachedResponse);
}
// send request to server and add response to cache
return next.handle(req).pipe(
tap(event => {
if (event instanceof HttpResponse) {
console.log(`Adding item to cache: ${req.url}`);
this.cacheService.put(req.url, event);
}
})
);
}
}
在上面的代码中有几点需要注意:
- 我们缓存的对象是连续的 GET 请求,正如代码展示的,一旦某个请求不为 GET 那么就会清除当前缓存,原因在于除了 GET 请求,POST PUT DELETE 都会改变数据库导致已经缓存的 GET 不准确;这里实际上可以优化一下,没必要清除所有的缓存,只需要清除受影响的缓存即可。
- 当成功获取客户端缓存之后,不再调用 next.handle 传递此请求,而是直接 return 被 of 包裹的缓存数据。
- 没有获取缓存,则使用响应拦截器,在成功得到数据之后保存到缓存中。
6. Module 6 -- 网络请求单元测试
6.1 使用 Angular 自带的单元测试工具
回顾一下单元测试的基本结构:
在 Angular 中,专门针对 Http 进行测试的模块有两个,它们是:HttpClientTestingModule
和 HttpTestingController
.
6.2 beforeEach 中的一般内容
在下面的代码中,展示单元测试时候 beforeEach 钩子的一般内容,其中 DataService
是待测试对象,而 HttpClientTestingModule
和 HttpTestingController
则是上文提到的辅助模块。
最引入注目的是,在单元测试中,实例化用的不是 new 而是 TestBed.get 方法。
6.3 单元测试的基本结构
在 beforeEach 中提供测试所需的支持之后,就可以针对用例进行测试了,一个基本的测试结构如下所示:
上面的代码展示了我们如何通过 dataService
发起网络请求,并使用 httpTestingController
接受响应值。
6.4 一个实际的例子
首先必须要说的是,所有的测试都应该放在名为 .spec.ts
后缀的 ts 文件中。
代码示例如下:
ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController, TestRequest } from '@angular/common/http/testing';
import { DataService } from './data.service';
import { Book } from 'app/models/book';
describe('DataService Tests', () => {
let dataService: DataService;
let httpTestingController: HttpTestingController;
let testBooks: Book[] = [
{ bookID: 1, title: 'Goodnight Moon', author: 'Margaret Wise Brown', publicationYear: 1947 },
{ bookID: 2, title: 'Winnie-the-Pooh', author: 'A. A. Milne', publicationYear: 1926 },
{ bookID: 3, title: 'The Hobbit', author: 'J. R. R. Tolkien', publicationYear: 1937 }
];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [DataService]
});
dataService = TestBed.get(DataService);
httpTestingController = TestBed.get(HttpTestingController);
});
afterEach(() => {
httpTestingController.verify();
});
it('should GET all books', () => {
dataService.getAllBooks().subscribe((data: Book[]) => {
expect(data.length).toBe(3);
let booksRequest: TestRequest = httpTestingController.expectOne('/api/books');
expect(booksRequest.request.method).toEqual('GET');
booksRequest.flush(testBooks);
});
});
it('should return an error', () => {
dataService.getAllBooks().subscribe(
(data: Book[]) => fail('this should have been an error'),
(err: any) => {
// Note: Assuming BookTrackerError is a custom error class. If not, replace with appropriate error type.
// Also, the exact properties of the error may vary depending on your implementation.
expect(err.status).toEqual(500);
expect(err.statusText).toEqual('Server Error');
let booksRequest: TestRequest = httpTestingController.expectOne('/api/books');
booksRequest.flush('error', { status: 500, statusText: 'Server Error' });
}
);
});
});
上述代码详细解释如下:
这段代码是一个Angular单元测试,用于测试名为DataService
的服务。这个服务很可能涉及到HTTP请求,用于获取书籍数据。测试使用了Angular的测试工具,特别是TestBed
和HttpTestingController
,来模拟HTTP请求和响应。
导入依赖
首先,代码导入了必要的依赖项,包括Angular的测试模块、HTTP测试模块,以及要测试的服务和模型。
测试设置
在describe
块中,设置了测试环境。定义了两个主要变量:dataService
(被测试的服务实例)和httpTestingController
(用于模拟HTTP请求的控制器)。还定义了一个testBooks
数组,用于模拟从服务器返回的书籍数据。
beforeEach 和 afterEach
beforeEach
:在每个测试用例执行之前运行。这里,它配置了测试模块,包括导入HttpClientTestingModule
和提供DataService
。然后,它初始化dataService
和httpTestingController
。afterEach
:在每个测试用例执行之后运行。这里,它调用httpTestingController.verify()
来确保没有未完成的HTTP请求。
测试用例
-
'should GET all books' :这个测试用例模拟了一个成功的GET请求,用于获取所有书籍。
- 通过
dataService.getAllBooks()
发起请求。 - 使用
subscribe
处理响应,期望返回的书籍数组长度为3。 - 使用
httpTestingController.expectOne('/api/books')
来捕获这个请求,并验证请求方法是GET。 - 使用
booksRequest.flush(testBooks)
来模拟服务器响应,返回预定义的testBooks
数组。
- 通过
-
'should return an error' :这个测试用例模拟了一个失败的GET请求,用于测试错误处理。
- 同样通过
dataService.getAllBooks()
发起请求。 - 在
subscribe
的第一个回调函数中,使用fail
函数来确保这个回调不应该被调用(因为这应该是一个错误情况)。 - 在
subscribe
的第二个回调函数(错误处理回调)中,验证返回的错误状态码和状态文本是否符合预期(500和'Server Error')。 - 使用
booksRequest.flush
来模拟一个错误响应。
- 同样通过
总结
这段代码是一个典型的Angular服务单元测试,用于验证DataService
中的getAllBooks
方法在各种情况下的行为。通过使用HttpTestingController
,它能够在不依赖实际后端服务的情况下模拟HTTP请求和响应,从而使测试更加独立和可控。