Angular的Http客户端

背景

计划单独用一个文档来说说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
/* config.service.ts */
configUrl = 'assets/config.json';

getConfig(): Observable<any> {
// 最简单的请求,只需要一个参数: 请求端点
return this.http.get(this.configUrl);
}

/* config.component.ts */
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
/** heroes.service.ts */
addHero(hero: Hero): Observable<Hero> {
return this.http.post<Hero>(this.heroesUrl, hero);
}

/** heroes.component.ts */
this.herosService.addHero(newHero).subscribe(hero => this.heroes.push(hero));

PUT请求

使用 put 函数发起,也可以和post请求一样返回一个服务器确认过的对象

DELETE请求

使用 delete 函数发起,服务器端可以什么也不返回,本地可以什么也不做,
不过必须有 subscribe

1
2
3
4
5
6
7
8
/** service中 */
deleteHero(id: number): Observable<{}> { // 由于不处理,假定了一个空对象作为内容
const url = `${this.heroesUrl}/${id}`;
return this.http.delete(url);
}

/** 组件中 */
this.heroesService.deleteHero(hero.id).subscribe();

错误处理

http客户端发起请求,通常遇到的错误有两种

  1. 本地客户端的错误(没有网络等等)
  2. 服务器返回了错误(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 {
// 如果是服务器错误,则可用status获取错误码,用error获取具体错误信息
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), // 最多重试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();

// 不要拼接url,可能不安全
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'});

设置header

angular当然支持配置请求头的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 为了安全,有足够的理由让选项成为const
const httpOptions = {
headers: new HttpHeaders({
'Content-Type': 'application/json',
Authorization: 'my-auth-token'
})
};

this.http.get(url, httpOptions);

// 但有时候的确需要更改头信息
// 可以使用set方法
httpOptions.headers =
httpOptions.headers.set('Authorization', 'my-new-auth-token');

设置响应体范围

使用 observe 关键字配置,
可选的值有

  • body(默认)
  • response(完整的响应体)
  • events(包含了取消请求的事件?)
1
2
3
4
5
6
7
// 注意由于设置了返回完整的结构,函数的返回值类型也发生了变化
// HttpResponse是响应体的类型
// body中的数据类型是Config
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
// 由于设置了数据类型是text,返回值的类型变成了string
getTextFile(filename: string): Observable<string> {
// 如果是string,不需要使用类型提示 get<string>
return this.http.get(filename, {responseType: 'text'})
.pipe(
tap( // 功能上类似监听版的subscribe,会做动作但不影响最终的subscribe
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)), // 进度信息由map函数从event中提取出来,以message的方式传给tap
tap(message => this.showProgress(message)), // 然后showProgress负责显示,话说这里没有用函数式编程
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 {
// req: 传入的请求, next: 下一级拦截器,且HttpHandler抽象类包含一个handle方法
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// 尽管大多数拦截器都是关注req,并对req做操作
// 但handle返回的也是Observable,如果这里使用pipe则可以对下级拦截器的返回值做监听
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
// 将http请求替换为https请求,更安全,因此这里修改请求内容比较合理
const secureReq = req.clone({
url: req.url.replace('http://', 'https://') // 克隆时只修改url字段
});
// 就修改过的请求传递向下游
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) {
// 从auth服务中拿到了登陆的凭证
const authToken = this.auth.getAuthorizationToken();

const authReq = req.clone({ // 克隆请求头
// 设置headers字段
// 同时设置时使用set函数
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) {
// req传进来的时候就开始计时
const started = Date.now();
// 除了计时,还有往日志里写一些备注,比如succeed,failed
let ok: string;

return next.handle(req).pipe(
tap(
// 有响应事件时,根据事件类型来确定要往日志里写的备注
event => ok = event instanceof HttpResponse ? 'succeeded' : '',
error => ok = 'failed'
),
// 无论该Observable结束还是出错,都执行
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) {
// 不妨认为isCacheable的判断依据是url是否符合特征
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>> {

// npm检索不允许有header信息,所以清除一下
const noHeaderReq = req.clone({ headers: new HttpHeaders() });

return next.handle(noHeaderReq).pipe(
tap(event => {
if (event instanceof HttpResponse) {
cache.put(req, event); // 缓存发来的event,注意这个event是HttpResponse类型的,可以用作服务器端返回值
}
})
);
}

一次请求多次返回

示例中展示的是先返回缓存中的结果,然后真正去查询后再次返回结果.

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) {
// 做搜索功能时,通常将搜索词当作节点放在已经建好的stream上
// 让 *规则* 统一以流的形式存在就挺好
this.searchText$.next(packageName);
}

ngOnInit() {
this.packages$ = this.searchText$.pipe(
debounceTime(500), // 组合拳1: 等待500ms
distinctUntilChanged(), // 组合拳2: 等待输入变化
switchMap(packageName => // 组合拳3: 取消没完成的请求
this.searchService.search(packageName, this.withRefresh))
);
}

constructor(private searchService: PackageSearchService) { }

TODO XSRF防护

为了防止不同域名下的代码向自己的后端发起请求.
angular的一套方案是

  1. client第一次和服务器通信时,获取一个xsrf令牌并存到cookie当中(存储时默认名字为 XSRF-TOKEN)
  2. 之后发起POST请求时,通过拦截器修改请求信息,读取cookie当中的值,并将token设置到header的 X-XSRF-TOKEN 当中
  3. 域名不同则不能读取cookie,因此只有自己域名的前端代码才能在请求头上带上正确的token
  4. 后端验证后放行

需要注意

  1. 默认情况下不修改get请求,绝对URL请求(带域名的)的请求头
  2. 不能由客户端自己生成令牌,通常是服务器端使用自己的认证信息加盐的摘要完成
  3. 如果一个域名下有多个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会自行提供一个拦截器用于完成这个工作?

参考

  1. 主要参考