头图

Angular 网络拦截器的项目实践

locotor

Angular Interceptor

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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req);
  }
}

这个接口只有一个 intercept 方法,有两个参数:

  • HttpRequest 代表请求对象,通过它可以修改请求;
  • HttpHandler 代表下一个拦截器,层层拦截器传递,直到 HttpBackend ,负责将请求通过浏览器的 HTTP API 发到后端服务器。
    HttpHandlerhandle 方法,最终返回一个 Observable,订阅就可以在请求的各个阶段拿到 HttpEvent 类型的值。例如,上传/下载进度、自定义事件、后端响应等。
    通过 Observablepipe 方法,就可以对响应做任意的修改。

要让这个拦截器生效,直接将其注入到根模块即可:

@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<any>) => 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<boolean>(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<any>, next: HttpHandler){
    loaderService.show()
    return next.handle(req).pipe(
        finalize(() => loaderService.hide())
    );
}

在负责全局加载动画的组件中,同样注入 loaderService,将 isGlobalLoading$ 赋值给动画的绑定属性,并借助 async 管道,实现动画的显隐切换。

@Component({
  selector: 'app-loader',
  template: `
    <div *ngIf="isLoading | async" class="overlay">
        <mat-progress-spinner mode="indeterminate">
        </mat-progress-spinner>
    </div>
  `,
})
export class LoaderComponent {
  isLoading: Subject<boolean> = this.loaderService.isLoading;
  constructor(private loaderService: LoaderService){}
}

3. 响应处理

就像前面提到的,对响应对象的 data 进行提取,简化请求回调的调用(避免这样的调用:httpResponse.data.data.something),除此以外,可以在拦截器中做模拟接口、格式处理,缓存数据等。

模拟响应

前后端分离开发的情况下,常常后端接口还未准备好,不过只需要在开发过程中模拟接口数据,跟网络请求相关的开发,如加载动画,接口服务,响应回调等,都可以顺利执行。

正如前面介绍的,过滤器是通过 next.handle(req) 来层层传递请求的。所以如果在模拟响应的拦截器中,停止调用这个方法,并返回包含模拟数据的 Observable 对象即可。

private mockApiList = new Map<string, object>([
    ['/example/getData', {
        code: '20000',
        data: { example: true }
    }]
]);

intercept(req: HttpRequest<any>, 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<any>) => {
        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<any>, 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 库一样。期待更多的开源库丰富框架生态 😋

阅读 788

玻璃晴朗,橘子辉煌

166 声望
1 粉丝
0 条评论

玻璃晴朗,橘子辉煌

166 声望
1 粉丝
文章目录
宣传栏