HTTP 客户端

HTTP 是浏览器和服务器之间通讯的主要协议。

WebSocket协议是另一种重要的通讯技术,但本章不会涉及它。

现代浏览器支持两种基于 HTTP 的 API: XMLHttpRequest (XHR)JSONP。少数浏览器还支持 Fetch

Angular的HTTP库,简化了使用XHRJSONP API的编程方式。

目录

Contents

我们在在线例子 / 可下载的例子中展示了这些主题。

演示

Demos

本章通过下面这些演示,描述了服务端通讯的用法。

这些演示由根组件AppComponent统一指挥。

src/app/app.component.ts

  1. import { Component } from '@angular/core';
  2. @Component({
  3. selector: 'my-app',
  4. template: `
  5. <hero-list></hero-list>
  6. <hero-list-promise></hero-list-promise>
  7. <my-wiki></my-wiki>
  8. <my-wiki-smart></my-wiki-smart>
  9. `
  10. })
  11. export class AppComponent { }

提供 HTTP 服务

Providing HTTP services

首先,配置应用来使用服务器通讯设施。

我们通过 Http 客户端,使用熟悉的 HTTP 请求/回应协议与服务器通讯。 Http客户端是Angular的HTTP库所提供的一系列服务之一。

当我们从@angular/http模块中导入服务时,SystemJS 知道该如何从Angular的HTTP库中加载它们, 这是因为systemjs.config.js文件已经注册过这个模块名。

要想使用Http客户端,你需要先通过依赖注入系统把它注册成一个服务提供商。

关于提供商的更多信息,见依赖注入

app.module.ts中通过导入其他模块来注册提供商到根 NgModule。

src/app/app.module.ts (v1)

import { NgModule }      from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule }   from '@angular/forms';
import { HttpModule, JsonpModule } from '@angular/http';

import { AppComponent } from './app.component';

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    JsonpModule
  ],
  declarations: [ AppComponent ],
  bootstrap:    [ AppComponent ]
})
export class AppModule {
}

我们从导入所需的成员开始,它们中的大多数我们都熟悉了,只有HttpModuleJsonpModule是新面孔。 关于导入和相关术语的更多信息,见 MDN reference 中的import语句。

只要把它们传给根模块的imports数组,就可以把这些模块加入应用。

我们需要 HttpModule 来发起 HTTP 调用。 普通的 HTTP 调用并不需要用到 JsonpModule, 不过稍后我们就会演示对 JSONP 的支持, 所以现在就加载它,免得再回来改浪费时间。

《英雄指南》的 HTTP 客户端演示

我们的第一个演示是《英雄指南(TOH)》教程的一个迷你版。 这个版本从服务器获取一些英雄,把它们显示在列表中,还允许我们添加新的英雄并将其保存到服务器。 借助 Angular 的 Http 客户端,我们通过XMLHttpRequest (XHR)与服务器通讯。

它跑起来是这样的:

ToH mini app

这个演示是一个单一组件HeroListComponent,其模板如下:

src/app/toh/hero-list.component.html

  1. <h1>Tour of Heroes ({{mode}})</h1>
  2. <h3>Heroes:</h3>
  3. <ul>
  4. <li *ngFor="let hero of heroes">{{hero.name}}</li>
  5. </ul>
  6. <label>New hero name: <input #newHeroName /></label>
  7. <button (click)="addHero(newHeroName.value); newHeroName.value=''">Add Hero</button>
  8. <p class="error" *ngIf="errorMessage">{{errorMessage}}</p>

它使用ngFor来展现这个英雄列表。 列表的下方是一个输入框和一个 Add Hero 按钮,在那里,我们可以输入新英雄的名字, 并把它们加到数据库中。 在(click)事件绑定中,使用模板引用变量newHeroName来访问这个输入框的值。 当用户点击此按钮时,这个值传给组件的addHero方法,然后清除它,以备输入新英雄的名字。

按钮的下方是一个错误信息区。

HeroListComponent

下面是这个组件类:

src/app/toh/hero-list.component.ts (class)

  1. export class HeroListComponent implements OnInit {
  2. errorMessage: string;
  3. heroes: Hero[];
  4. mode = 'Observable';
  5. constructor (private heroService: HeroService) {}
  6. ngOnInit() { this.getHeroes(); }
  7. getHeroes() {
  8. this.heroService.getHeroes()
  9. .subscribe(
  10. heroes => this.heroes = heroes,
  11. error => this.errorMessage = <any>error);
  12. }
  13. addHero(name: string) {
  14. if (!name) { return; }
  15. this.heroService.create(name)
  16. .subscribe(
  17. hero => this.heroes.push(hero),
  18. error => this.errorMessage = <any>error);
  19. }
  20. }

Angular会把一个HeroService注入到组件的构造函数中,该组件将调用此服务来获取和保存数据。

这个组件不会直接和 Angular 的 Http 客户端打交道! 它既不知道也不关心我们如何获取数据,这些都被委托给了HeroService去做。

这是一条黄金法则:总是把数据访问工作委托给一个支持性服务类

虽然在运行期间,组件会在创建之后立刻请求这些英雄数据, 但我们在组件的构造函数中调用此服务的get方法。 而是在ngOnInit生命周期钩子中调用它, Angular 会在初始化该组件时调用ngOnInit方法。

这是最佳实践。 当组件的构造函数足够简单,并且所有真实的工作(尤其是调用远端服务器) 都在一个独立的方法中处理时,组件会更加容易测试和调试。

服务的getHeroes()addHero()方法返回一个英雄数据的可观察对象 (Observable), 这些数据是由 Angular 的 Http 客户端从服务器上获取的。

我们可以把可观察对象Observable看做一个由某些“源”发布的事件流。 通过订阅到可观察对象Observable,我们监听这个流中的事件。 在这些订阅中,我们指定了当 Web 请求生成了一个成功事件(有效载荷是英雄数据) 或失败事件(有效载荷是错误对象)时该如何采取行动。

有了对组件的基本理解,我们可以到HeroService的内部实现中看看。

通过 http.get() 获取数据

在前面的很多例子中,我们通过在服务中返回一个模拟的英雄列表来伪造了与服务器的交互过程。就像这样:

import { Injectable } from '@angular/core';

import { Hero } from './hero';
import { HEROES } from './mock-heroes';

@Injectable()
export class HeroService {
  getHeroes(): Promise<Hero[]> {
    return Promise.resolve(HEROES);
  }
}

在本章中,我们会修改HeroService,改用 Angular 的 Http 客户端来从服务器上获取英雄列表:

src/app/toh/hero.service.ts (revised)

  1. import { Injectable } from '@angular/core';
  2. import { Http, Response } from '@angular/http';
  3. import { Observable } from 'rxjs/Observable';
  4. import 'rxjs/add/operator/catch';
  5. import 'rxjs/add/operator/map';
  6. import { Hero } from './hero';
  7. @Injectable()
  8. export class HeroService {
  9. private heroesUrl = 'api/heroes'; // URL to web API
  10. constructor (private http: Http) {}
  11. getHeroes(): Observable<Hero[]> {
  12. return this.http.get(this.heroesUrl)
  13. .map(this.extractData)
  14. .catch(this.handleError);
  15. }
  16. private extractData(res: Response) {
  17. let body = res.json();
  18. return body.data || { };
  19. }
  20. private handleError (error: Response | any) {
  21. // In a real world app, you might use a remote logging infrastructure
  22. let errMsg: string;
  23. if (error instanceof Response) {
  24. const body = error.json() || '';
  25. const err = body.error || JSON.stringify(body);
  26. errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
  27. } else {
  28. errMsg = error.message ? error.message : error.toString();
  29. }
  30. console.error(errMsg);
  31. return Observable.throw(errMsg);
  32. }
  33. }

注意,这个 Angular Http 客户端服务被注入到了HeroService的构造函数中。

constructor (private http: Http) {}

仔细看看我们是如何调用http.get

src/app/toh/hero.service.ts (getHeroes)

getHeroes(): Observable<Hero[]> {
  return this.http.get(this.heroesUrl)
                  .map(this.extractData)
                  .catch(this.handleError);
}

我们把资源的 URL 传进get函数,它调用了服务器,而服务器应该返回英雄列表。

一旦我们按附录中所描述的那样准备好了内存 Web API,它将返回英雄列表。 但目前,你可以临时性地使用一个 JSON 文件,修改一下 URL:

private heroesUrl = 'app/heroes.json'; // URL to JSON file

返回值可能会让我们感到意外。 对熟悉现代 JavaScript 中的异步调用方法的人来说,我们期待get方法返回一个承诺 (promise)。 我们期待链接调用then()方法,并从中取得英雄列表。 而这里调用了一个map()方法,显然,它不是承诺 (Promise)。

事实上,http.get方法返回了一个 HTTP Response 类型的可观察对象 (Observable<Response>),这个对象来自 RxJS 库,而map()是 RxJS 的操作符之一。

RxJS库

RxJS是一个被Angular认可的第三方库,它是异步可观察对象模式的一种实现。

开发指南中的所有例子都安装了 RxJS 的 npm 包, 这是因为可观察对象在 Angular 应用中使用非常广泛。 HTTP 客户端更需要它。但还要经过一个关键步骤,我们才能用 RxJS 可观察对象: 我们必须单独导入一些RxJS的操作符。

启用 RxJS 操作符

RxJS 库实在是太大了。 当构建一个产品级应用,并且把它发布到移动设备上的时候,大小就会成为一个问题。 我们应该只包含那些我们确实需要的特性。

每个代码文件都需要把它需要的操作符从RxJS库中导入,并添加进来。 getHeroes()方法需要一个map()和一个catch()操作符,那就像这样导入它:

src/app/app.component.ts (import rxjs)

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/map';

处理响应对象

记住,getHeroes()借助一个extractData()辅助方法来把http.get的响应对象映射成了英雄列表:

src/app/toh/hero.service.ts (excerpt)

private extractData(res: Response) {
  let body = res.json();
  return body.data || { };
}

这个response对象并没有以一种我们能直接使用的格式来保存数据。 要让它在应用程序中可用,我们就必须把这个响应数据解析成一个 JSON 对象。

解析成 JSON

响应数据是 JSON 字符串格式的。 我们必须把这个字符串解析成 JavaScript 对象 —— 只要调一下response.json()就可以了。

这不是 Angular 自己的设计。 Angular HTTP 客户端遵循 ES2015 规范来处理Fetch函数返回的响应对象。 此规范中定义了一个json()函数,来把响应体解析成 JavaScript 对象。

我们不应该期待解码后的 JSON 直接就是一个英雄数组。 调用的这个服务器总会把 JSON 结果包装进一个带data属性的对象中。 我们必须解开它才能得到英雄数组。这是一个约定俗成的 Web API 行为规范,它是出于 安全方面的考虑

不要对服务端 API 做任何假设。 并非所有服务器都会返回一个带data属性的对象。

不要返回响应对象

getHeroes()确实可以返回 HTTP 响应对象,但这不是最佳实践。 数据服务的重点在于,对消费者隐藏与服务器交互的细节。 调用HeroService的组件希望得到英雄数组。 它并不关心我们如何得到它们。 它也不在乎这些数据从哪里来。 毫无疑问,它也不希望直接和一个响应对象打交道。

HTTP 的 GET 方法被推迟执行了

http.get仍然没有发送请求!这是因为可观察对象是 冷的, 也就是说,只有当某人订阅了这个可观察对象时,这个请求才会被发出。 这个场景中的某人就是HeroListComponent

总是处理错误

一旦开始与 I/O 打交道,我们就必须准备好接受墨菲定律:如果一件倒霉事可能发生,它就迟早会发生。 我们可以在HeroService中捕获错误,并对它们做些处理。 只有在用户可以理解并采取相应行动的时候,我们才把错误信息传回到组件,让组件展示给最终用户。

在这个简单的应用中,我们在服务和组件中都只提供了最原始的错误处理方式。

src/app/toh/hero.service.ts (excerpt)

getHeroes(): Observable<Hero[]> {
  return this.http.get(this.heroesUrl)
                  .map(this.extractData)
                  .catch(this.handleError);
}

private handleError (error: Response | any) {
  // In a real world app, you might use a remote logging infrastructure
  let errMsg: string;
  if (error instanceof Response) {
    const body = error.json() || '';
    const err = body.error || JSON.stringify(body);
    errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
  } else {
    errMsg = error.message ? error.message : error.toString();
  }
  console.error(errMsg);
  return Observable.throw(errMsg);
}

catch()操作符将错误对象传递给httphandleError()方法。 服务处理器 (handleError) 把响应对象记录到控制台中, 把错误转换成对用户友好的消息,并且通过Observable.throw来把这个消息放进一个新的、用于表示“失败”的可观察对象。

app/toh/hero.service.ts (excerpt)

出错的文件: ../../../_fragments/server-communication/ts/app/toh/hero.service-error-handling.ts.md   所在路径: docs,ts,latest,guide,server-communication 文档路径: ../../../

HeroListComponent 错误处理

回到HeroListComponent,这里我们调用了heroService.getHeroes()。我们提供了subscribe函数的第二个参数来处理错误信息。 它设置了一个errorMessage变量,被有条件的绑定到了HeroListComponent模板中。

src/app/toh/hero-list.component.ts (getHeroes)

getHeroes() {
  this.heroService.getHeroes()
                   .subscribe(
                     heroes => this.heroes = heroes,
                     error =>  this.errorMessage = <any>error);
}

想看到它失败时的情况吗?在HeroService中把 API 的端点设置为一个无效值就行了。但别忘了恢复它。

往服务器发送数据

前面我们已经看到如何用一个 HTTP 服务从远端获取数据了。 但我们还能再给力一点,让它可以创建新的英雄,并把它们保存到后端。

我们将为HeroListComponent创建一个简单的create()方法,它将接受新英雄的名字,并且返回一个Hero型的Observable,代码如下:

create(name: string): Observable<Hero> {

要实现它,我们得知道关于服务端 API 如何创建英雄的一些细节。

我们的数据服务器遵循典型的 REST 指导原则。 它期待在和GET英雄列表的同一个端点上存在一个POST请求。 它期待从请求体 (body) 中获得新英雄的数据,数据的结构和Hero对象相同,但是不带id属性。 请求体应该是这样的:

{ "name": "Windstorm" }

服务器将生成id,并且返回新英雄的完整JSON形式,包括这个生成的 id。 该英雄的数据被塞进一个响应对象的data属性中。

现在,知道了这个 API 如何工作,我们就可以像这样实现create()了:

src/app/toh/hero.service.ts (additional imports)

import { Headers, RequestOptions } from '@angular/http';

src/app/toh/hero.service.ts (create)

  create(name: string): Observable<Hero> {
    let headers = new Headers({ 'Content-Type': 'application/json' });
    let options = new RequestOptions({ headers: headers });

    return this.http.post(this.heroesUrl, { name }, options)
                    .map(this.extractData)
                    .catch(this.handleError);
  }

请求头 (headers)

我们通过Content-Type头告诉服务器,body 是 JSON 格式的。

接下来,使用headers对象来配置options对象。 options对象是RequestOptions的新实例,该类允许你在实例化请求时指定某些设置。这样, HeadersRequestOptions 中的一员。

return声明中,options是传给post()方法的第三个参数,就像前面见过的那样。

JSON 结果

getHeroes()中一样,我们可以使用extractData()辅助函数从响应中提取出数据

回到HeroListComponent,我们看到该组件的addHero()方法中订阅了这个由服务中create()方法返回的可观察对象。 当有数据到来时,它就会把这个新的英雄对象追加 (push) 到heroes数组中,以展现给用户。

src/app/toh/hero-list.component.ts (addHero)

addHero(name: string) {
  if (!name) { return; }
  this.heroService.create(name)
                   .subscribe(
                     hero  => this.heroes.push(hero),
                     error =>  this.errorMessage = <any>error);
}

倒退为承诺 (Promise)

虽然 Angular 的http客户端 API 返回的是Observable<Response>类型的对象,但我们也可以把它转成 Promise<Response>。 这很容易,并且在简单的场景中,一个基于承诺 (Promise) 的版本看起来很像基于可观察对象 (Observable) 的版本。

可能“承诺”看起来更熟悉一些,但“可观察对象”有很多优越之处。

下面是使用承诺重写HeroService,要特别注意那些不同的部分。

  1. getHeroes (): Promise<Hero[]> {
  2. return this.http.get(this.heroesUrl)
  3. .toPromise()
  4. .then(this.extractData)
  5. .catch(this.handleError);
  6. }
  7. addHero (name: string): Promise<Hero> {
  8. let headers = new Headers({ 'Content-Type': 'application/json' });
  9. let options = new RequestOptions({ headers: headers });
  10. return this.http.post(this.heroesUrl, { name }, options)
  11. .toPromise()
  12. .then(this.extractData)
  13. .catch(this.handleError);
  14. }
  15. private extractData(res: Response) {
  16. let body = res.json();
  17. return body.data || { };
  18. }
  19. private handleError (error: Response | any) {
  20. // In a real world app, we might use a remote logging infrastructure
  21. let errMsg: string;
  22. if (error instanceof Response) {
  23. const body = error.json() || '';
  24. const err = body.error || JSON.stringify(body);
  25. errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
  26. } else {
  27. errMsg = error.message ? error.message : error.toString();
  28. }
  29. console.error(errMsg);
  30. return Promise.reject(errMsg);
  31. }

在本例中,你可以遵循承诺的then(this.extractData).catch(this.handleError)模式。

另外,你也可以调用toPromise(success, fail)。可观察对象的map第一个参数为成功时的回调函数, 它的第二个参数用.toPromise(this.extractData, this.handleError)来拦截失败。

我们的errorHandler也改用了一个失败的承诺,而不再是失败的可观察对象。

把诊断信息记录到控制台也只是在承诺的处理链中多了一个then()而已。

我们还得对调用方组件进行调整,让它期待一个Promise而非Observable

  1. getHeroes() {
  2. this.heroService.getHeroes()
  3. .then(
  4. heroes => this.heroes = heroes,
  5. error => this.errorMessage = <any>error);
  6. }
  7. addHero (name: string) {
  8. if (!name) { return; }
  9. this.heroService.addHero(name)
  10. .then(
  11. hero => this.heroes.push(hero),
  12. error => this.errorMessage = <any>error);
  13. }

唯一一个比较明显的不同点是我们调用这个返回的承诺的then()方法,而不再是subscribe。 我们给了这两个方法完全相同的调用参数。

细微却又关键的不同点是,这两个方法返回了非常不同的结果!

基于承诺的then()返回了另一个承诺。我们可以链式调用多个then()catch()方法,每次都返回一个新的承诺。

subscribe()方法返回一个Subscription对象。但Subscription不是另一个Observable。 它是可观察对象的末端。我们不能在它上面调用map()函数或再次调用subscribe()函数。 Subscription对象的设计目的是不同的,这从它的主方法unsubscribe就能看出来。

要理解订阅的实现和效果,请看 Ben Lesh 关于可观察对象的演讲 或者他在 egghead.io 的课程。

跨域请求:Wikipedia 范例

我们刚刚学习了用 Angular Http 服务发起XMLHttpRequests。 这是与服务器通讯时最常用的方法。 但它不适合所有场景。

出于安全的考虑,网络浏览器会阻止调用与当前页面不“同源”的远端服务器的XHR。 所谓就是 URI 的协议 (scheme)、主机名 (host) 和端口号 (port) 这几部分的组合。 这被称为同源策略

在现代浏览器中,如果服务器支持 CORS 协议,那么也可以向不同源的服务器发起XHR请求。 如果服务器要请求用户凭证,我们就在请求头中启用它们。

有些服务器不支持 CORS,但支持一种老的、只读的(译注:即仅支持 GET)备选协议,这就是 JSONP。 Wikipedia就是一个这样的服务器。

这个 StackOverflow 上的答案覆盖了关于 JSONP 的很多细节。

搜索 Wikipedia

我们来构建一个简单的搜索程序,当我们在文本框中输入时,它会从 Wikipedia 中获取并显示建议的词汇列表:

Wikipedia search app (v.1)

Wikipedia 提供了一个现代的CORS API和一个传统的JSONP搜索 API。在这个例子中,我们使用后者。 Angular 的Jsonp服务不但通过 JSONP 扩展了Http服务,而且限制我们只能用GET请求。 尝试调用所有其它 HTTP 方法都将抛出一个错误,因为 JSONP 是只读的。

像往常一样,我们把和 Angular 数据访问服务进行交互的代码全都封装在一个专门的服务中。我们称之为WikipediaService

src/app/wiki/wikipedia.service.ts

  1. import { Injectable } from '@angular/core';
  2. import { Jsonp, URLSearchParams } from '@angular/http';
  3. import 'rxjs/add/operator/map';
  4. @Injectable()
  5. export class WikipediaService {
  6. constructor(private jsonp: Jsonp) {}
  7. search (term: string) {
  8. let wikiUrl = 'http://en.wikipedia.org/w/api.php';
  9. let params = new URLSearchParams();
  10. params.set('search', term); // the user's search value
  11. params.set('action', 'opensearch');
  12. params.set('format', 'json');
  13. params.set('callback', 'JSONP_CALLBACK');
  14. // TODO: Add error handling
  15. return this.jsonp
  16. .get(wikiUrl, { search: params })
  17. .map(response => <string[]> response.json()[1]);
  18. }
  19. }

这个构造函数期望 Angular 给它注入一个jsonp服务。 前面我们已经把JsonpModule导入到了根模块中,所以这个服务已经可以使用了。

搜索参数

Wikipedia 的 'opensearch' API 期待在所请求的 URL 中带四个查询参数(键/值对格式)。 这些键 (key) 分别是searchactionformatcallbacksearch的值是用户提供的用于在 Wikipedia 中查找的关键字。 另外三个参数是固定值,分别是 "opensearch"、"json" 和 "JSONP_CALLBACK"。

JSONP技术需要我们通过查询参数传给服务器一个回调函数的名字:callback=JSONP_CALLBACK。 服务器使用这个名字在它的响应体中构建一个 JavaScript 包装函数,Angular 最终会调用这个包装函数来提取出数据。 这些都是 Angular 在背后默默完成的,你不会感受到它。

如果我们要找那些含有关键字 “Angular” 的文档,我们可以先手工构造出查询字符串,并像这样调用jsonp

let queryString =
  `?search=${term}&action=opensearch&format=json&callback=JSONP_CALLBACK`;

return this.jsonp
           .get(wikiUrl + queryString)
           .map(response => <string[]> response.json()[1]);

在更加参数化的例子中,我们会首选 Angular 的URLSearchParams辅助类来构建查询字符串,就像这样:

src/app/wiki/wikipedia.service.ts (search parameters)

let params = new URLSearchParams();
params.set('search', term); // the user's search value
params.set('action', 'opensearch');
params.set('format', 'json');
params.set('callback', 'JSONP_CALLBACK');

这次我们使用了两个参数来调用jsonpwikiUrl和一个配置对象,配置对象的search属性是刚构建的这个params对象。

src/app/wiki/wikipedia.service.ts (call jsonp)

// TODO: Add error handling
return this.jsonp
           .get(wikiUrl, { search: params })
           .map(response => <string[]> response.json()[1]);

Jsonpparams对象平面化为一个查询字符串,而这个查询字符串和以前我们直接放在请求中的那个是一样的。

WikiComponent 组件

现在,我们有了一个可用于查询 Wikpedia API 的服务, 我们重新回到组件中,接收用户输入,并显示搜索结果。

src/app/wiki/wiki.component.ts

  1. import { Component } from '@angular/core';
  2. import { Observable } from 'rxjs/Observable';
  3. import { WikipediaService } from './wikipedia.service';
  4. @Component({
  5. selector: 'my-wiki',
  6. template: `
  7. <h1>Wikipedia Demo</h1>
  8. <p>Search after each keystroke</p>
  9. <input #term (keyup)="search(term.value)"/>
  10. <ul>
  11. <li *ngFor="let item of items | async">{{item}}</li>
  12. </ul>`,
  13. providers: [ WikipediaService ]
  14. })
  15. export class WikiComponent {
  16. items: Observable<string[]>;
  17. constructor (private wikipediaService: WikipediaService) { }
  18. search (term: string) {
  19. this.items = this.wikipediaService.search(term);
  20. }
  21. }

该模板有一个<input>元素,它是用来从用户获取搜索关键词的搜索框。 在每次keyup事件被触发时,它调用search(term)方法。

search(term)方法委托WikipediaService服务来完成实际操作。 该服务返回的是一个字符串数组的可观察对象 (Observable<string[]>)。 没有像HeroListComponent那样在组件内部订阅这个可观察对象, 我们把这个可观察对象作为结果传给模板(通过items属性), 模板中ngFor上的 async(异步)管道会对这个订阅进行处理。 关于异步管理的更多信息,见 Pipes

我们通常在只读组件中使用异步管道,这种组件不需要与数据进行互动。

但我们不能在HeroListComponent中使用这个管道,这是因为addHero()会把一个新创建的英雄追加到英雄列表中。

奢侈的应用程序

这个 Wikipedia 搜索程序触发了过多的服务器调用。 这样效率很低,而且在流量受限的移动设备上会显得过于昂贵。

1. 等用户停止输入

我们目前会在每次按键之后调用服务器。 但合理的方式是只在用户停止输入之后才发起请求。 重构之后,它将这样工作:

Wikipedia search app (v.2)

2. 当搜索关键字变化了才搜索

假设用户在输入框中输入了单词 angular,然后稍等片刻。 应用程序就会发出一个对 angular 的搜索请求。

然后,用户用退格键删除了最后三个字符 lar,并且毫不停顿的重新输入了 lar。 搜索关键词仍然是 “angular”。这时应用程序不应该发起另一个请求。

3. 对付乱序响应体

用户输入了 angular,暂停,清除搜索框,然后输入 http。 应用程序发起了两个搜索请求,一个搜 angular,一个搜 http

哪一个响应会先回来?我们是没法保证的。 即使有多个尚未返回的请求,应用程序也应该按照原始请求的顺序展示对它们的响应。 如果能让 angular 的结果始终在后面返回,就不会发生这样的混乱了。

Observable 的更多乐趣

借助一些漂亮的可观察对象操作符,我们可以解决这些问题,并改进我们的应用程序。

我们本可以把这些改动合并进WikipediaService中,但是为了更好用户体验, 我们创建一个WikiComponent的复本,让它变得更智能。 下面是WikiSmartComponent,它使用同样的模板:

这里是WikiSmartComponent组件,就显示在原WikiComponent的紧后面:

  1. import { Component, OnInit } from '@angular/core';
  2. import { Observable } from 'rxjs/Observable';
  3. import 'rxjs/add/operator/debounceTime';
  4. import 'rxjs/add/operator/distinctUntilChanged';
  5. import 'rxjs/add/operator/switchMap';
  6. import { Subject } from 'rxjs/Subject';
  7. import { WikipediaService } from './wikipedia.service';
  8. @Component({
  9. selector: 'my-wiki-smart',
  10. template: `
  11. <h1>Smarter Wikipedia Demo</h1>
  12. <p>Search when typing stops</p>
  13. <input #term (keyup)="search(term.value)"/>
  14. <ul>
  15. <li *ngFor="let item of items | async">{{item}}</li>
  16. </ul>`,
  17. providers: [ WikipediaService ]
  18. })
  19. export class WikiSmartComponent implements OnInit {
  20. items: Observable<string[]>;
  21. constructor (private wikipediaService: WikipediaService) {}
  22. private searchTermStream = new Subject<string>();
  23. search(term: string) { this.searchTermStream.next(term); }
  24. ngOnInit() {
  25. this.items = this.searchTermStream
  26. .debounceTime(300)
  27. .distinctUntilChanged()
  28. .switchMap((term: string) => this.wikipediaService.search(term));
  29. }
  30. }

虽然它们的模板几乎相同,但是这个“智能”版涉及到了更多RxJS,比如debounceTimedistinctUntilChangedswitchMap操作符, 就像前面提过的那样导入。

创建一个搜索关键词的流

每当按键时,WikiComponent就会把一个新的搜索词直接传给WikipediaService

WikiSmartComponent类借助一个Subject实例把用户的按键传给一个搜索关键词的可观察流(Observable stream)。Subject是从RxJS中导入的。

import { Subject } from 'rxjs/Subject';

组件创建searchTermStreamstring类型的Subjectsearch()方法通过subjectnext()方法,将每个新搜索框的值添加到数据流中。

private searchTermStream = new Subject<string>();
search(term: string) { this.searchTermStream.next(term); }

监听搜索词

WikiSmartComponent监听搜索关键词的流,并且可以在调用搜索服务之前处理这个流。

this.items = this.searchTermStream
  .debounceTime(300)
  .distinctUntilChanged()
  .switchMap((term: string) => this.wikipediaService.search(term));

switchMap的角色是至关重要的。 WikipediaService为每个搜索请求返回一个独立的字符串数组型的可观察对象(Observable<string[]>)。 在一个慢速服务器有时间回复之前,用户可能会发起多个请求,这意味着这个流中的响应体可能在任何时刻、以任何顺序抵达客户端。

switchMap返回一个自有的可观察对象,该对象组合WikipediaService中的所有响应体, 并把它们按原始请求顺序排列,只把最近一次返回的搜索结果提交给订阅者。

预防跨站请求伪造攻击

在一个跨站请求伪造攻击(CSRF 或 XSRF)中,攻击者欺骗用户访问一个不同的网页,它带有恶意代码,秘密向你的应用程序服务器发送恶意请求。

客户端和服务器必须合作来抵挡这种攻击。 Angular 的http客户端自动使用它默认的CookieXSRFStrategy来完成客户端的任务。

CookieXSRFStrategy支持常见的反 XSRF 技术,服务端发送一个随机生成的认证令牌到名为XSRF-TOKEN的 cookie 中。 HTTP 客户端使用该令牌的值为所有后续请求添加一个X-XSRF-TOKEN页头。 服务器接受这个 cookie 和页头,比较它们,只有在它们匹配的时候才处理请求。

参见"安全"一章的XSRF主题,以了解关于XSRF和Angular的应对措施XSRFStrategy的更多信息。

覆盖默认的请求头(及其它请求选项)

请求选项(比如请求头),会在发起请求之前并入default RequestOptions中。 HttpModule通过RequestOptions令牌提供了这些默认选项。

我们可以通过创建一个RequestOptions的子类来把这些默认值覆盖为本应用中的默认选项,以适应应用中的需求。

这个例子创建了一个类,它把默认的Content-Type请求头设置为JSON。 它导出了一个带有RequestOptions提供商的常量,以便注册进AppModule中。

src/app/default-request-options.service.ts

import { Injectable } from '@angular/core';
import { BaseRequestOptions, RequestOptions } from '@angular/http';

@Injectable()
export class DefaultRequestOptions extends BaseRequestOptions {

  constructor() {
    super();

    // Set the default 'Content-Type' header
    this.headers.set('Content-Type', 'application/json');
  }
}

export const requestOptionsProvider = { provide: RequestOptions, useClass: DefaultRequestOptions };

然后,它在根模块AppModule中注册了这个提供商。

src/app/app.module.ts (provide default request header)

providers: [ requestOptionsProvider ],

在对应用的HTTP服务进行单元测试时,别忘了在初始化代码中包含这个提供商。

修改之后,HeroService.create()中的header选项就不再需要了。

src/app/toh/hero.service.ts (create)

  create(name: string): Observable<Hero> {
    let headers = new Headers({ 'Content-Type': 'application/json' });
    let options = new RequestOptions({ headers: headers });

    return this.http.post(this.heroesUrl, { name }, options)
                    .map(this.extractData)
                    .catch(this.handleError);
  }

打开浏览器开发者工具的network页,我们就可以确认,DefaultRequestOptions工作是否正常。 如果你通过某些机制(比如内存Web API)短路了对服务器的调用, 可以试试注释掉create头选项,在POST调用上设置断点,并单步跟踪进请求过程来验证是否添加了这个头。

独立的请求选项,比如这一个,优先级高于默认的RequestOptions,保留create的请求头设置,以增强安全性,是一个明智之举。

附录:《英雄指南》的内存 (in-memory) 服务器

如果我们只关心获取到的数据,我们可以告诉 Angular 从一个heroes.json文件中获取英雄列表,就像这样:

src/app/heroes.json

{
  "data": [
    { "id": 1, "name": "Windstorm" },
    { "id": 2, "name": "Bombasto" },
    { "id": 3, "name": "Magneta" },
    { "id": 4, "name": "Tornado" }
  ]
}

我们把英雄数组包装进一个带data属性的对象中,就像一个真正的数据服务器所应该做的那样。 这样可以缓解由顶级 JSON 数组导致的安全风险

我们要像这样把端点设置为这个 JSON 文件:

src/app/toh/hero.service.ts

private heroesUrl = 'app/heroes.json'; // URL to JSON file

这在获取英雄数据的场景下确实能工作, 但我们不能把这些改动保存到 JSON 文件中,因此需要一个 Web API 服务器。 因为这个演示程序中并没有一个真实的服务器, 所以,我们使用内存 Web API 仿真器代替它。

内存Web API并不是Angular本身的一部分。 它是一个可选服务,要用npm来单独安装angular-in-memory-web-api库(参见package.json)。

参见README file来了解配置选项、默认行为和限制。

内存 Web API 从一个带有createDb()方法的自定义类中获取数据,并且返回一个 map,它的主键 (key) 是一组名字,而值 (value) 是一组与之对应的对象数组。

这里是与范例中基于 JSON 的数据源完成相同功能的类:

src/app/hero-data.ts

import { InMemoryDbService } from 'angular-in-memory-web-api';
export class HeroData implements InMemoryDbService {
  createDb() {
    let heroes = [
      { id: 1, name: 'Windstorm' },
      { id: 2, name: 'Bombasto' },
      { id: 3, name: 'Magneta' },
      { id: 4, name: 'Tornado' }
    ];
    return {heroes};
  }
}

确保HeroService的端点指向了这个 Web API:

src/app/toh/hero.service.ts

private heroesUrl = 'api/heroes';  // URL to web API

使用内存 Web API 服务模块很容易配置重定向,将InMemoryWebApiModule添加到AppModule.imports列表中, 同时在HeroData类中调用forRoot()配置方法。

src/app/app.module.ts

InMemoryWebApiModule.forRoot(HeroData)

工作原理

这次重定向非常容易配置,这是因为 Angular 的http服务把客户端/服务器通讯的工作委托给了一个叫做XHRBackend的辅助服务。

使用标准 Angular 提供商注册方法,InMemoryWebApiModule替代默认的XHRBackend服务并使用它自己的内存存储服务。 forRoot方法来自模拟的英雄数据集的种子数据初始化了这个内存 Web API。

forRoot()方法的名字告诉我们,应该只在设置根模块AppModule时调用InMemoryWebApiModule一次。不要再次调用它。

下面是修改过的(也是最终的)app/app.module.ts版本,用于演示这些步骤。

src/app/app.module.ts (excerpt)

import { NgModule }      from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule }   from '@angular/forms';
import { HttpModule, JsonpModule } from '@angular/http';


import { InMemoryWebApiModule }     from 'angular-in-memory-web-api';
import { HeroData }                 from './hero-data';
import { requestOptionsProvider }   from './default-request-options.service';

import { AppComponent }             from './app.component';

import { HeroListComponent }        from './toh/hero-list.component';
import { HeroListPromiseComponent } from './toh/hero-list.component.promise';

import { WikiComponent }      from './wiki/wiki.component';
import { WikiSmartComponent } from './wiki/wiki-smart.component';

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    JsonpModule,
    InMemoryWebApiModule.forRoot(HeroData)
  ],
  declarations: [
    AppComponent,
    HeroListComponent,
    HeroListPromiseComponent,
    WikiComponent,
    WikiSmartComponent
  ],
  providers: [ requestOptionsProvider ],
  bootstrap: [ AppComponent ]
})
export class AppModule {}

HttpModule之后导入InMemoryWebApiModule,确保XHRBackend的供应商InMemoryWebApiModule取代所有其它的供应商。

要想查看完整的源代码,请参见在线例子 / 可下载的例子