背景
计划单独用一个文档来说说angular中http客户端的一些用法
使用前提
需要在模块中引入 HttpClientModule
1 2 3 4 5 6 7 8 9 10
| import { HttpClientModule } from '@angular/common/http';
@NgModule({ imports: [ HttpClientModule, ], }) export class AppModule {}
|
也要在需要使用的文件中注入服务,通常起名 http
1 2 3
| export class ConfigService { constructor(private http: HttpClient) { } }
|
发起请求
angular是rxjs友好的,使用 HttpClient
发起的请求会返回 Obesrvable
,这样方便后续处理.
另外在一般的实践中,通常会在抽象出来的service中发起请求,而在组件中对发起请求的返回值做出处理.
另外 HttpClient
发起的请求是 冷的, 意味着如果不使用 subscribe
则不会真正发起请求.
也因此可以用一个变量装一个请求双原型,然后 subscribe
两次就发出两个请求.
最简使用
通常GET请求是最简单的.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| configUrl = 'assets/config.json';
getConfig(): Observable<any> { return this.http.get(this.configUrl); }
showConfig() { this.configService.getConfig() .subscribe((data:any) => this.config = { heroesUrl: data.heroesUrl, textfile: data.textfile }); }
|
一般来讲可以不为返回值断言类型.
即使在有严格类型检查的情况下依然可以声明为any,然后我行我素地使用点符号却不会报错.
但如果做了类型断言,则可以方便获取到值之后的赋值操作.
指定类型
先定义一个类型
1 2 3 4
| export interface Config { heroesUrl: string; textfile: string; }
|
然后在使用get方法时使用类型提示.
这里的类型会在编译时作为类型断言.
1 2 3
| getConfig() { return this.http.get<Config>(this.configUrl); }
|
然后在赋值时就不用再手写大量赋值语句,直接解构即可.
1 2 3 4
| showConfig() { this.configService.getConfig() .subscribe((data: Config) => this.config = { ...data }); }
|
POST请求
使用post请求则需要多加一个参数,以向服务器传递数据.
一个常见的实践是,服务器也返回那个新建的对象(带上了id),然后本地列表会添加上这个对象.
1 2 3 4 5 6 7
| addHero(hero: Hero): Observable<Hero> { return this.http.post<Hero>(this.heroesUrl, hero); }
this.herosService.addHero(newHero).subscribe(hero => this.heroes.push(hero));
|
PUT请求
使用 put
函数发起,也可以和post请求一样返回一个服务器确认过的对象
DELETE请求
使用 delete
函数发起,服务器端可以什么也不返回,本地可以什么也不做,
不过必须有 subscribe
1 2 3 4 5 6 7 8
| deleteHero(id: number): Observable<{}> { const url = `${this.heroesUrl}/${id}`; return this.http.delete(url); }
this.heroesService.deleteHero(hero.id).subscribe();
|
错误处理
http客户端发起请求,通常遇到的错误有两种
- 本地客户端的错误(没有网络等等)
- 服务器返回了错误(404,500等)
为了程序的健壮需要定义错误处理程序.
当遇到错误时,应尽量向下游的observer抛出一个包含信息的Observable
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| private handleError(error: HttpErrorResponse) { if (error.error instanceof ErrorEvent) { console.error('An error occurred:', error.error.message); } else { console.error( `Backend returned code ${error.status}, ` + `body was: ${error.error}`); } return throwError( 'Something bad happened; please try again later.'); }
|
然后在每个请求中都用pipe处理一下,
甚至还可以重试几次
1 2 3 4 5 6 7
| getConfig() { return this.http.get<Config>(this.configUrl) .pipe( retry(3), catchError(this.handleError) ); }
|
使用参数
http发起请求可以使用第三个参数 options
, 参数的用途也不少,比如
- 传递查询参数(链接中问号后的内容)
- 设置header字段
- 做出其他配置
- 返回响应体的范围
- 返回响应体body的数据格式
- 显示进度
传递查询参数
有搜索词功能的应用应该会很常用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| searchHeroes(term: string): Observable<Hero[]> { term = term.trim();
const options = term ? { params: new HttpParams().set('name', term) } : {};
return this.http.get<Hero[]>(this.heroesUrl, options) .pipe( catchError(this.handleError<Hero[]>('searchHeroes', [])) ); }
const params = new HttpParams({fromString: 'name=foo'});
|
angular当然支持配置请求头的信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json', Authorization: 'my-auth-token' }) };
this.http.get(url, httpOptions);
httpOptions.headers = httpOptions.headers.set('Authorization', 'my-new-auth-token');
|
设置响应体范围
使用 observe
关键字配置,
可选的值有
- body(默认)
- response(完整的响应体)
- events(包含了取消请求的事件?)
1 2 3 4 5 6 7
|
getConfigResponse(): Observable<HttpResponse<Config>> { return this.http.get<Config>( this.configUrl, { observe: 'response' }); }
|
设置响应体body数据格式
使用 responseType
关键字配置,
可选的值有
- json(默认值)
- text
- blob
- arraybuffer
1 2 3 4 5 6 7 8 9 10 11
| getTextFile(filename: string): Observable<string> { return this.http.get(filename, {responseType: 'text'}) .pipe( tap( data => this.log(filename, data), error => this.logError(filename, error) ) ); }
|
显示进度
使用 repostProgress
关键字来配置,开或关
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const req = new HttpRequest('POST', '/upload/file', file, { reportProgress: true });
this.http.request(req).pipe( map(event => this.getEventMessage(event, file)), tap(message => this.showProgress(message)), last(), catchError(this.handleError(file)) )
this.http.post('/upload/file', file, {reportProgress: true}).pipe( map(event => this.getEventMessage(event, file)), tap(message => this.showProgress(message)), last(), catchError(this.handleError(file)) );
|
其中 getEventMessage
定义如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| private getEventMessage(event: HttpEvent<any>, file: File) { switch (event.type) { case HttpEventType.Sent: return `Uploading file "${file.name}" of size ${file.size}.`;
case HttpEventType.UploadProgress: const percentDone = Math.round(100 * event.loaded / event.total); return `File "${file.name}" is ${percentDone}% uploaded.`;
case HttpEventType.Response: return `File "${file.name}" was completely uploaded!`;
default: return `File "${file.name}" surprising upload event: ${event.type}.`; } }
|
参数使用时的注意
由于大多数参数是一系列定值字符串组成的 联合类型
因此指定参数时需要明确使用联合类型而非字符串
1 2 3 4 5 6 7 8 9 10 11 12
| const options = { responseType: 'text', }; client.get('/foo', options)
const options = { responseType: 'text' as const, }; client.get('/foo', options);
client.get('/foo', {responseType: 'text'})
|
拦截器
许多web框架中都有拦截器的概念,通常是多级的拦截器,一级处理完就传递给下一级
使用拦截器有许多用途
- 设置默认请求头(由于readonly问题,需要使用req.clone方法)
- 记录日志
- 实现缓存
- 一次请求多次返回(先返回一个快速的结果,再返回一个正确的结果)
定义
需要实现 HttpInterceptor
接口的 intercept
函数
1 2 3 4 5 6 7 8 9
| @Injectable() export class NoopInterceptor implements HttpInterceptor { intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { return next.handle(req); } }
|
TODO 使用
似乎只需要在模块文件中,定义一个provide语句即可让拦截器生效?
比如提供一个拦截器
1 2 3 4 5 6 7 8 9 10 11
| @NgModule({ providers: [ { provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true } ] }) export class CustomerModule {}
|
如果要使用多个拦截器,由于还要注意顺序,可以像路由定义一样从模块定义中独立出来,封装一下
1 2 3 4 5 6 7
| import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { NoopInterceptor } from './noop-interceptor';
export const httpInterceptorProviders = [ { provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true }, ];
|
然后在模块中引用
1 2 3
| providers: [ httpInterceptorProviders ]
|
此处注意顺序,如果定义的是A,B,C
那么在拦截请求时顺序是 A->B->C
, 在拦截响应时顺序是 C->B->A
修改请求参数
为了安全,默认传入的request对象是只读的,即
1 2
| req.url = req.url.replace('http://', 'https://');
|
不过这种防范不能防范深修改
1
| req.body.name = req.body.name.trim();
|
如果真的要修改则需要使用 req.clone()
1 2 3 4 5 6
| const secureReq = req.clone({ url: req.url.replace('http://', 'https://') });
return next.handle(secureReq);
|
特别提示如果想清除所有请求体,可以设置body为null,
不能设置为undefined,这样会被认为想保持原样
1 2 3 4 5 6
| newReq = req.clone({ body: null });
newReq = req.clone({ ... }); newReq = req.clone({ body: undefined });
|
设置默认请求头
参照上面修改请求内容的方法,以及修改header内容的方法就可以完成
通常的用途是,当用户登陆后获取到一个登陆凭证用的token,
每次请求时带上这个凭证告诉服务器自己有权限
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Injectable() export class AuthInterceptor implements HttpInterceptor {
constructor(private auth: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler) { const authToken = this.auth.getAuthorizationToken();
const authReq = req.clone({ headers: req.headers.set('Authorization', authToken) });
const authReq = req.clone({ setHeaders: { Authorization: authToken } });
return next.handle(authReq); } }
|
记录日志
此处演示对请求和响应都监视的做法
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
| @Injectable() export class LoggingInterceptor implements HttpInterceptor { constructor(private logService: LogService) {}
intercept(req: HttpRequest<any>, next: HttpHandler) { const started = Date.now(); let ok: string;
return next.handle(req).pipe( tap( event => ok = event instanceof HttpResponse ? 'succeeded' : '', error => ok = 'failed' ), finalize(() => { const elapsed = Date.now() - started; const content = `${req.method} "${req.urlWithParams}" ${ok} in ${elapsed} ms.`; this.logService.add(content); }) ); } }
|
实现缓存
有些请求,比如查询npm仓库中有什么软件包,
完全可以在client层就将查询条件和对应的结果缓存起来,
不要抛给后端.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Injectable() export class CachingInterceptor implements HttpInterceptor { constructor(private cache: RequestCache) {}
intercept(req: HttpRequest<any>, next: HttpHandler) { if (!isCacheable(req)) { return next.handle(req); }
const cachedResponse = this.cache.get(req); return cachedResponse ? of(cachedResponse) : sendRequest(req, next, this.cache); } }
|
其中 sendRequest
担负了发送请求,同时缓存结果的功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function sendRequest( req: HttpRequest<any>, next: HttpHandler, cache: RequestCache): Observable<HttpEvent<any>> {
const noHeaderReq = req.clone({ headers: new HttpHeaders() });
return next.handle(noHeaderReq).pipe( tap(event => { if (event instanceof HttpResponse) { cache.put(req, event); } }) ); }
|
一次请求多次返回
示例中展示的是先返回缓存中的结果,然后真正去查询后再次返回结果.
1 2 3 4 5 6
| if (req.headers.get('x-refresh')) { const results$ = sendRequest(req, next, this.cache); return cachedResponse ? results$.pipe( startWith(cachedResponse) ) : results$; }
|
防抖
所谓防抖,就是短间隔的多次输入,整理为一次输入.
比如一个没有搜索按钮的搜索框,每当用户输入字符就即将发起搜索.
常常会遇到之前的请求还在进行中(比如网络不良),
新的请求已经要发出(比如用户输入变化太快).
此时通常会有一些惯用办法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| withRefresh = false; packages$: Observable<NpmPackageInfo[]>; private searchText$ = new Subject<string>();
search(packageName: string) { this.searchText$.next(packageName); }
ngOnInit() { this.packages$ = this.searchText$.pipe( debounceTime(500), distinctUntilChanged(), switchMap(packageName => this.searchService.search(packageName, this.withRefresh)) ); }
constructor(private searchService: PackageSearchService) { }
|
TODO XSRF防护
为了防止不同域名下的代码向自己的后端发起请求.
angular的一套方案是
- client第一次和服务器通信时,获取一个xsrf令牌并存到cookie当中(存储时默认名字为
XSRF-TOKEN
)
- 之后发起POST请求时,通过拦截器修改请求信息,读取cookie当中的值,并将token设置到header的
X-XSRF-TOKEN
当中
- 域名不同则不能读取cookie,因此只有自己域名的前端代码才能在请求头上带上正确的token
- 后端验证后放行
需要注意
- 默认情况下不修改get请求,绝对URL请求(带域名的)的请求头
- 不能由客户端自己生成令牌,通常是服务器端使用自己的认证信息加盐的摘要完成
- 如果一个域名下有多个angular应用,则需要在向cookie中存储时使用
XSRF-TOKEN
以外的多种名称以防冲突
使用该功能:
1 2 3 4 5 6 7 8 9 10 11
| @NgModule({ imports: [ HttpClientModule, HttpClientXsrfModule.withOptions({ cookieName: 'My-Xsrf-Cookie', headerName: 'My-Xsrf-Header', }), ], }) export class AppModule {}
|
似乎引入模块后angular会自行提供一个拦截器用于完成这个工作?
参考
- 主要参考