Angular 的 HttpClient 模块,除了可以发起各种网络请求,同时也提供了网络拦截器的功能。在拦截器中,即可以获取到请求对象,也能获取到这个请求的服务器端响应对象,因此,可以通过拦截器统一处理各种网络相关功能。毕竟统一处理,意味着更好的可维护性,程序更健壮,还有最重要的,更能“偷懒”了。毕竟,懒惰是软件工程师的美德!
在 Angular 中,注册一个拦截器,其实就是一个实现了 HttpInterceptor
接口的类:
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
export class ExampleInjector implements HttpInterceptor {
intercept(req: HttpRequest, next: HttpHandler): Observable> {
return next.handle(req);
}
}
这个接口只有一个 intercept
方法,有两个参数:
HttpRequest
代表请求对象,通过它可以修改请求;HttpHandler
代表下一个拦截器,层层拦截器传递,直到HttpBackend
,负责将请求通过浏览器的 HTTP API 发到后端服务器。
而HttpHandler
的handle
方法,最终返回一个Observable
,订阅就可以在请求的各个阶段拿到HttpEvent
类型的值。例如,上传/下载进度、自定义事件、后端响应等。
通过Observable
的pipe
方法,就可以对响应做任意的修改。
要让这个拦截器生效,直接将其注入到根模块即可:
@NgModule({
...
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: ExampleInjector, multi: true }
],
bootstrap: [AppComponent]
})
export class AppModule { }
注意这个 multi
参数,表示可以注入多个拦截器,它们的是典型的链式传递,按这个 providers
数组的顺序,请求按顺序,响应按倒序。
另外,除了 HttpClient,常用的 Axios 库,在功能是类似的,具体实现不同,但在工程上的是实践情况是一样的。
在平常的项目中,拦截器的使用主要可以分为三个类型。
1. 修改请求
修改请求是拦截器最常见的情况了,要注意的是,HttpRequest
是只读的,因为一个网络请求对象可能重试多次,所以必须确保每次经过拦截器时,都是原始请求对象。要修改请求对象,可以通过它的 clone
方法,创建一个副本,并传递给下一个拦截器。
修改 URL
例如,咱们想从 HTTP 变为 HTTPS。
// 克隆请求,同时使用 https:// 替换 http://
const reqClone = req.clone({
url: req.url.replace('http://', 'https://')
});
return next.handler(reqClone)
或者给添加服务器端的 IP 和端口,负责给 API 统一加前缀。
req.clone({
url: environment.serverUrl + request.url
});
不过,通常情况下,可以用更好去解决,例如 HTTPS 可以通过 ng serve -ssl
来实现。而请求路径的需求,配置 CLI 自带的 Webpack 开发服务器更合适,除了重写路径,它还可以支持细致化的配置代理,解决跨域。
设置 Header
相比修改 URL 或者 请求 Body,修改请求的 Header 就常见了很多。例如上篇介绍了如何使用 JWT 来进行登录验证,在前端发起请求时,Header 中就需要携带这个 JWT。
另外,由于在克隆请求的同时设置新请求头的操作太常见了,还提供一个快捷方式 setHeaders
:
const reqClone = req.clone({ setHeaders: { Authorization: JWT } });
return next.handler(reqClone)
2. 统一处理
相比在每次请求的时候显式的去处理一些任务,通过拦截器统一的隐式处理就好了很多。例如错误处理,相信大家平常也不爱在每次请求返回 Promise/Observable 后,在 then/subscribe 中,除了配置成功的回调,还要写错误处理吧。这样既麻烦,重复劳动多,还容易出错。
日志记录
拦截器能够同时获取到请求和响应,很适合记录 HTTP 请求的完整生命周期日志。例如,捕获请求和响应时间,记录经过的时间结果。
const started = Date.now()
let ok: string = '';
return next.handle(req).pipe(
tap(
(event: HttpEvent) => ok = event instanceof HttpResponse ? 'succeeded' : '',
(error: HttpEventResponse) => ok = 'failed'
),
// 响应结束的时候记日志
finalize(() => {
const elapsed = Date.now() - started;
const msg = `${req.method} "${req.urlWithParams}" ${ok} in ${elapsed} ms.`
console.log(msg);
})
)
错误处理
在响应返回后,如果出现网络错误,应该根据 HTTP 的状态码,做对应处理。
先配置各种 HTTP 状态的提示信息:
// 各种 HTTP 状态码对应的提示信息
const HTTP_MESSAGES = new Map([
[200, '服务器成功返回请求的数据'],
[201, '新建或修改数据成功'],
[202, '请求已经进入后台排队(异步任务)'],
[204, '删除数据成功'],
[400, '发出的请求有错误,服务器没有进行新建或修改数据的操作'],
[401, '用户没有权限(令牌、用户名、密码错误)'],
[403, '用户得到授权,但是访问是被禁止的'],
[404, '发出的请求针对的是不存在的记录,服务器没有进行操作'],
[406, '请求的格式不可得'],
[410, '请求的资源被永久删除,且不会再得到的'],
[422, '当创建对象时,发生一个验证错误'],
[500, '服务器发生错误,请检查服务器'],
[502, '网关错误'],
[503, '服务不可用,服务器暂时过载或维护'],
[504, '网关超时'],
]);
在请求出现错误时,根据 HttpErrorResponse
的 status 属性,提示错误信息。
return next.handle(req).pipe(
catchError((err: HttpErrorResponse) => {
let errorMessage: { message: string; };
if (HTTP_MESSAGES.has(err.status)) {
errorData = { message: CODE_MESSAGE.get(err.status) };
} else {
errorData = { message: '未知错误,请联系管理员' };
}
// 封装一个提示服务类,和第三方的 UI 库解耦。
this.MessageService.error(errorData.message);
// 抛出异常消息
return throwError(errorData);
}),
);
针对特定的状态码,跳转页面:
switch (err.status) {
case 401:
this.router.navigateByUrl('/authentication/login');
break;
case 403:
case 404:
case 500:
this.router.navigateByUrl(`/exception/${err.status}`);
break;
}
通知提示
除了常见的网络错误,通常后端会返回特定的状态码,以及对应的用户提示信息,可以在拦截器统一处理。并且,在处理完成后,就可以返回纯 data 值,简化订阅响应时的代码。
return next.handle(req).pipe(
mergeMap((ev) => {
// 只处理 HttpEvent 类型为响应对象的事件
if (ev instanceof HttpResponse) {
// 约定 20000 为执行成功
if (body && body.code === '20000') {
return of({ data: body.data });
} else {
// 执行失败,提示错误信息,并返回 null
this.MessageService.error(errorData.message);
return of(null);
}
}
return of(ev);
})
);
加载状态
有些时候,设计可能希望在页面发生跳转或网络请求时,全局显示一个加载动画提示,就像 Angular 官网顶部的加载进度条。网络请求部分,就可以通过拦截器来实现。
首先创建一个服务,用于在拦截器和组件间共享服务。
@Injectable({ providedIn: 'root' })
export class LoaderService {
private isGlobalLoading = new BehaviorSubject(false);
public isGlobalLoading$ = this.isGlobalLoading.asObservable();
public show(): void {
this.isGlobalLoading.next(true);
}
public hide(): void {
this.isGlobalLoading.next(false);
}
}
在这个拦截器中,注入 loaderService
。发起请求时开启动画,响应结束后,通过 finalize
操作符,关闭动画。
intercept(req: HttpRequest, next: HttpHandler){
loaderService.show()
return next.handle(req).pipe(
finalize(() => loaderService.hide())
);
}
在负责全局加载动画的组件中,同样注入 loaderService
,将 isGlobalLoading$
赋值给动画的绑定属性,并借助 async
管道,实现动画的显隐切换。
@Component({
selector: 'app-loader',
template: `
= this.loaderService.isLoading;
constructor(private loaderService: LoaderService){}
}
`,
})
export class LoaderComponent {
isLoading: Subject
3. 响应处理
就像前面提到的,对响应对象的 data 进行提取,简化请求回调的调用(避免这样的调用:httpResponse.data.data.something
),除此以外,可以在拦截器中做模拟接口、格式处理,缓存数据等。
模拟响应
前后端分离开发的情况下,常常后端接口还未准备好,不过只需要在开发过程中模拟接口数据,跟网络请求相关的开发,如加载动画,接口服务,响应回调等,都可以顺利执行。
正如前面介绍的,过滤器是通过 next.handle(req)
来层层传递请求的。所以如果在模拟响应的拦截器中,停止调用这个方法,并返回包含模拟数据的 Observable 对象即可。
private mockApiList = new Map([
['/example/getData', {
code: '20000',
data: { example: true }
}]
]);
intercept(req: HttpRequest, next: HttpHandler) {
if(this.mockApiList.has(req.url){
// 若匹配需要模拟的接口路径,则返回模拟数据
return of(this.mockApiList.get(req.url));
}else{
// 其他请求正常传递给下一个拦截器
return next.handle(req);
}
}
为了和缓存,或者数据过滤等过滤器协同,模拟响应的过滤器要放在 Providers 的最后。
发散一下,我们可以维护一个服务,如 mockApiList
一样,配置需要模拟的接口地址和模拟数据,正常请求其他没有配置的接口路径。正如 Delon/Mock
库所做的,变成一个通用性的模拟接口库。
格式转换
有时候后端的同学可能会返回如 “SOMETHING_EXAMPLE” 的字段,但为了保持前端工程的一致性,我们还是统一将字段转换为小驼峰比较好。为了简单,这里用到了 lodash 的 mapKey 和 camelCase。
return next.handle(req).pipe(
map((event: HttpEvent) => {
if (event instanceof HttpResponse) {
let camelCaseBody = mapKeys(event.body, (v,k) => camelCase(k));
const modEvent = event.clone({ body: camelCaseBody });
return modEvent;
}
})
)
数据缓存
在应用生命周期中,有些接口可能只需要获取一次,之后从缓存中获取,以便提升性能。这些需要缓存的接口,可以统一配置在拦截器中。
定义一个需要缓存的接口列表,判断每次请求是否需要缓存,不需要,则直接跳过;
如果需要,则先尝试从缓存服务中获取,如果有,就跳过所有拦截器直接返回数据,没有则发起请求获取,并存入缓存服务。
private cacheApis = ['/user/userDetail'];
// 注入缓存服务
constructor(private cache: CacheService) {}
intercept(req: HttpRequest, next: HttpHandler) {
if (!isNeedCache(req)) { return next.handle(req); }
// 尝试从缓存服务中获取
const cachedResponse = this.cache.get(req);
if(cachedResponse){
return of(cachedResponse) // 直接返回缓存数据
}
else{
return next.handle(req).pipe(
tap(event => {
if (event instanceof HttpResponse) {
cache.put(req, event); // 向后台获取数据,存入缓存服务
}
})
);
}
}
isNeedCache():boolean {
return this.cacheApis.indexOf(req.url) !== -1;
}
总结
拦截器的设计思想在很多框架里都有应用,就像之前介绍的 Spring Security 就是通过一组过滤器链来实现、Servlet 的过滤器、或是常用的 Axios 库中都有体现。适合隐式地完成各种通用性网络操作,在项目中有很多的用法,这里只是列举了平常项目中用到的部分,希望能够发掘更多创造性的开发实践。
另外,从前面提到的模拟接口就可以看出,除了以上基本的使用,同样还可以通过拦截器封装更方便,功能丰富的库,简化开发,统一的处理网络事务,就像 Delon 库一样。期待更多的开源库丰富框架生态