HTTP主要是浏览器与服务端的通信协议,流行浏览器支持XHRJSONP两种基于HTTP API,少数浏览器还需要 Fetch

Angular2 HTTP客户端封装了 XHRJSONP API。

一、示例

我们使用Angular Http 客户端访问XHR。

http-toh

依然以前面一直用的 Hero 的示例,只不过这回是从服务端获取、保存数据。一个父组件 TohComponent 和子组件 HeroListComponent,这和我们之前看到样本示例都一样,只不过改装成从服务端交互。

app/toh/toh.component.ts

import { Component }         from 'angular2/core';
import { HTTP_PROVIDERS }    from 'angular2/http';
import { HeroListComponent } from './hero-list.component';
import { HeroService }       from './hero.service';
@Component({
  selector: 'my-toh',
  template: `
  <h1>Tour of Heroes</h1>
  <hero-list></hero-list>
  `,
  directives: [HeroListComponent],
  providers:  [
    HTTP_PROVIDERS,
    HeroService,
  ]
})
export class TohComponent { }

像往常一样,我们要先导入所需要的模块。这一次要使用Angular HTTP库的 HTTP_PROVIDERS(如果你依然是用官网示例做为项目种子,要先引用 http.dev.js);同时还导入 HeroService 这也是我们之前一直在用的用于获取 hero 数据的类。同时还需要注入DI中。

另外 HTTP_PROVIDERS 提供一些基本的HTTP请求操作,比如:HttpXHRBackendRequestOptionsResponseOptions 等,所以最佳的方式,可以在Angular2启动时就注册注入:

import { HTTP_PROVIDERS } from 'angular2/http';
bootstrap(TohComponent, [HTTP_PROVIDERS]);

接者 HeroListComponent 组件模板内容:

app/toh/hero-list.component.html

<h3>Heroes:</h3>
<ul>
  <li *ngFor="let hero of heroes">
    {{ hero.name }}
  </li>
</ul>
New Hero:
<input #newHero />
<button (click)="addHero(newHero.value); newHero.value=''">
  Add Hero
</button>
<div class="error" *ngIf="errorMessage">{{errorMessage}}</div>

首先是一个利用 NgFor 循环指令显示列表。列表的下方是一文本框和Add Hero按钮,可以输入新名称并将它添加到数据库中;使用本地模板变量 newHero 访问值和按钮 (click) 事件绑定,当用户点击按钮时,调用 addHero 组件方法并清空文本框的内容。最下方是一个用于提示错误消息。

app/toh/hero-list.component.ts

export class HeroListComponent implements OnInit {
  constructor (private _heroService: HeroService) {}
  errorMessage: string;
  heroes:Hero[];
  ngOnInit() { this.getHeroes(); }
  getHeroes() {
    this._heroService.getHeroes()
                     .subscribe(
                       heroes => this.heroes = heroes,
                       error =>  this.errorMessage = <any>error);
  }
  addHero (name: string) {
    if (!name) {return;}
    this._heroService.addHero(name)
                     .subscribe(
                       hero  => this.heroes.push(hero),
                       error =>  this.errorMessage = <any>error);
  }
}

在构造函数里注入 HeroService,它在父组件 TohComponent 已经被注册过,所以子组件是可以直接被注入。

注意这里并不与服务端有任何直接联系,这一切都委派 heroService 类来处理。

1、黄金法则

始终将数据访问委派给Service类

2、最佳实践

虽然在组件运行时要请求 heroes 列表,但是我们并没有在构造函数里调用Service的 get 方法。而是利用 ngOnInit 生命周期钩子,当实例化组件时Angular自动调用 ngOnInit 方法。

当其构建函数很简单或需要一些调用远程服务时,最好是使用生命周期钩子,这样更有利于测试和调试。为什么这么说呢?试想如果在构造函数里调用一个远程服务时有异常,那实例到底应该是成功还是失败呢?

组件类的 getaddHero 方法返回的 hero 数据是一个 Observable 类型。我们可以订阅它的请求和失败观察者。

现在组件基本上已经弄好了,现在重点要关注 HeroService 客户端与后端数据源的交互。

2、获取数据

系列文章里有很多 HeroService 示例,并且还模拟从服务端请求数据的过程,比如:

import {Injectable} from 'angular2/core';

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

@Injectable()
export class HeroService {
  getHeroes() {
    return Promise.resolve(HEROES);
  }
}

当然,我们现在是要真正从服务器获取 heroes 数据,所以有个全新的 HeroService 类:

import {Injectable}     from 'angular2/core';
import {Http, Response} from 'angular2/http';
import {Hero}           from './hero';
import {Observable}     from 'rxjs/Observable';
@Injectable()
export class HeroService {
  constructor (private http: Http) {}
  private _heroesUrl = 'app/heroes';  // URL to web api
  getHeroes (): Observable<Hero[]> {
    return this.http.get(this._heroesUrl)
                    .map(this.extractData)
                    .catch(this.handleError);
  }
  private extractData(res: Response) {
    if (res.status < 200 || res.status >= 300) {
      throw new Error('Bad response status: ' + res.status);
    }
    let body = res.json();
    return body.data || { };
  }
  private handleError (error: any) {
    // In a real world app, we might send the error to remote logging infrastructure
    let errMsg = error.message || 'Server error';
    console.error(errMsg); // log to console instead
    return Observable.throw(errMsg);
  }
}

我们通过加载Angular Http 客户端服务并把它注入到 HeroService 构造函数中。

Http 并不是 angular2/core 的一部分,这是一个可选服务在 angular2/http 库里。此外,该库并不是核心Angular脚本文件里,所以还需要在 index.html 的引入。

<script src="node_modules/angular2/bundles/http.dev.js"></script>

仔细看看我们怎么调用 http.get

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

get 方法传递资源URL,它会访问服务端并返回 heroes。

返回的结果可能会很惊讶,因为我们会比较期待返回一个 promise,这样我们可以使用 then() 来获取数据,然后我们调用了 map() 方法,而非 promise。

事实上,http.get 方法返回的是一个HTTP响应 Observable 对象,由RxJS库提供,map() 也是RxJS的一个操作符。

3、RxJS库

RxJS是一个第三方库,由Angular赞助,实现异步观察模式

官网示例默认就有安装 RxJS,并且也在 index.html 里引入脚本,因为Observable模式被广泛在应用在Angular程序。其实早在 Angualr 作者在针对 1.x 被访谈时就已经说过Observable才是未来。

HTTP客户端就需要RxJS,我们必须采用额外的步骤开启RxJS Observables。

4、启用RxJS操作符

RxJS库非常大,当我们构建一个用于移动设备的应用程序时,应该只包括我们实际需要的功能。因此,Angular在 rxjs/Observable 模块中提供一简化版 Observable,但是缺乏几乎所有操作,包括 getHeroes 中的 map 方法。

我们需要哪些RxJS操作符,按需加载即可。但是现在我们是学习HTTP,不计较这些,所以全部加载吧。

// Add all operators to Observable
import 'rxjs/Rx';

5、处理响应对象

记得,我们的 getHeroes 方法中的 map() 方法,会将提取数据映射到 this.extractData 方法。

private extractData(res: Response) {
  if (res.status < 200 || res.status >= 300) {
    throw new Error('Bad response status: ' + res.status);
  }
  let body = res.json();
  return body.data || { };
}

response 对象并不是返回我们可以直接使用的数据,要想变成应用程序所需要的数据需要:

  • 检查不良响应
  • 解析响应数据

错误状态码

示例中的状态码200-300范围从应用角度来说是错误,但对于 http 角度来说并非错误,所以先判断状态码并抛出一个错误。而对于 404 - Not Found 像其他一样会有响应,我们发送一请求出去,然后返回一个响应,这对于 http 来说是错误的,所以会立即得到一个 observable 错误。

因为状态码200-300范围从应用角度来说是错误,所以我们拦截并抛出,移动 observable 链到错误路径。而 catch 操作来处理我们抛出的错误。

解析JSON

响应数据返回的是一个JSON字符串格式,我们必须调用 response.json() 转换成JavaScript对象。这并非Angular自行设计的,这是因为Angular HTTP Client使用的是ES2015规范中的 Fetch 方法返回响应对象,它定义了一个 json() 方法来解析响应内容成JavaScript对象。

JSON劫持

我们不应该让 json() 直接返回一个数组 hero,而应该是返回一个带有 data 属性的对象,比如:{data: [ hero, hero ] }

这是因对于老式浏览器可能会有JSON被劫持安全漏洞,这是另外一个话题了,大概就是老式浏览器可以重写 Array.prototype.constructor 从而达到拦截JSON的内容,安全问题不再过多叙述。

不要直接返回response对象

getHeroes() 能够直接返回 Observable<Response>,但这不是好主意,Server类的目的就是为了向用户隐藏与服务器交互细节,组件只需调用 HeroService 获取想要 hreoes,谁管你什么是Response。

记得处理错误

细心的读者可能已经发现我们使用 catch 操作符并结合 handleError 方法,但并未讨论实际的工作方式,每当我们处理 I/O 时,必须要做到处理错误的准备。

HeroService 捕获错误并返回一个对用户更友好、能理解的消息,handleError 方法是 catch 操作符委托的处理方法。

getHeroes (): Observable<Hero[]> {
  return this.http.get(this._heroesUrl)
                  .map(this.extractData)
                  .catch(this.handleError);
}
private handleError (error: any) {
  // In a real world app, we might send the error to remote logging infrastructure
  let errMsg = error.message || 'Server error';
  console.error(errMsg); // log to console instead
  return Observable.throw(errMsg);
}

二、HeroListComponent的订阅

回到 HeroListComponent,要调用 heroService.getHeroessubscribe 提供的第二个函数用来处理错误消息,并把错误消息赋值给 errorMessage 变量。

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

三、发送数据到服务端

到目前为止,我们使用Angular内置的 Http 服务从远程检索数据,现在该到保存数据到后端了。

我们将在 HeroListComponent 组件创建一个简单的 addHero 方法,只需要一个name(字符串)参数,结果返回一个新 hero 的 observable 类型。

addHero (name: string) : Observable<Hero>

要实现这一点,我们需要了解服务器应用程序接口的一些细节来创建 hero。我们数据服务遵循REST风格,一个端点可以通过请求POST或GET来创建或检索。使用POST请求时,只需要提供一个无 id 属性的 Hero 实体,并把数据放在body里,例如:

{ "name": "Windstorm" }

服务器将生成新 id 并返回包括生成id的整个实体对象,并用一个 data 属性包裹返回响应。

app/toh/hero.service.ts

import {Headers, RequestOptions} from 'angular2/http';

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

  let body = JSON.stringify({ name });
  let headers = new Headers({ 'Content-Type': 'application/json' });
  let options = new RequestOptions({ headers: headers });

  return this.http.post(this._heroesUrl, body, options)
    .map(this.extractData)
    .catch(this.handleError);
}

post 方法第二个参数需要一个 JSON 字符串,所以这里用 JSON.stringify 将对象转化成字符串。

1、Headers

POST请求需要指明一个 Content-Type 请求体,Headers是一个RequestOptions类型,并作为 post 方法的第三个参数。

app/toh/hero.service.ts

let headers = new Headers({ 'Content-Type': 'application/json' });
let options = new RequestOptions({ headers: headers });

return this.http.post(this._heroesUrl, body, options)

2、JSON结果

如同 getHeroes,我们使用 json() 从响应中提取数据,也是没有把 hero 包裹至 data 属性。

回到 HeroListComponent,我们看到了 _heroService.addHero 方法返回一个可被订阅的观察对象,当数据返回时,把新对象推到数组当中。

app/toh/hero-list.component.ts

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

3、~~回到Promise~~

虽然Angular http 客户端API返回的是 Observable<Response>,如果愿意,可以把它变成一个 Promise。

使用Promise重写 HeroService

getHeroes (): Promise<Hero[]> {
  return this.http.get(this._heroesUrl)
                  .toPromise()
                  .then(this.extractData)
                  .catch(this.handleError);
}
addHero (name: string): Promise<Hero> {
  let body = JSON.stringify({ name });
  let headers = new Headers({ 'Content-Type': 'application/json' });
  let options = new RequestOptions({ headers: headers });
  return this.http.post(this._heroesUrl, body, options)
             .toPromise()
             .then(this.extractData)
             .catch(this.handleError);
}
private extractData(res: Response) {
  if (res.status < 200 || res.status >= 300) {
    throw new Error('Bad response status: ' + res.status);
  }
  let body = res.json();
  return body.data || { };
}
private handleError (error: any) {
  // In a real world app, we might send the error to remote logging infrastructure
  let errMsg = error.message || 'Server error';
  console.error(errMsg); // log to console instead
  return Promise.reject(errMsg);
}

把 Observable 转换成 Promise 最简单就是调用 toPromise(success, fail),把observable的 map 回调移到第一个成功参数,catch 回调移到第二个错误参数就行了。或者也可以用 then.catch 模式。

errorHandler 里返回一个失败的promise而非Observable。

然后调整组件使用 Promise

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

最明显的区别是,把 subscribe 变成 then

从代码角度虽然只是变换了一个单词,但是还是返回的结果有很大的区别。

基于promise的 then 返回的另一个promise,我们可以继续接着链式调用多个 thencatch,每一次都会返回新prromise。

subscribe 方法返回的是一个 Subscription,这对于observable来讲已经是终结,我们不能够再调用 mapsubscribe

四、JSONP

我们只是学会了如何使用内置的 Http 服务创建一个 XMLHttpRequests,这是服务器通信中最常用的方法,但并非适用所有情况。

出于安全考虑,网页浏览器有一种叫同源策略机制,即无法访问其它域的资源。而流行浏览器允许 XHR 利用CORS协议请求跨域资源共享,这除了浏览器支持外还需要服务端允许。但一些老式浏览器并不支持或服务端因为某些原因无法设置CORS,那么我们需要用 JSONP 来解决只读跨域问题。

1、维基百科搜索

维基百科提供了一个 JSONP 搜索API,让我们弄个简单的维基百科搜索。

wiki-1

Angular的 Jsonp 服务其实是扩展了 Http 服务,并限制只允许 GET 请求,所有其它HTTP方法都会直接抛错,因为JSONP只允许只读请求。

一如既往,我们把所有交互细节都由Service来完成:

app/wiki/wikipedia.service.ts

import {Injectable} from 'angular2/core';
import {Jsonp, URLSearchParams} from 'angular2/http';
@Injectable()
export class WikipediaService {
  constructor(private jsonp: Jsonp) {}
  search (term: string) {
    let wikiUrl = 'http://en.wikipedia.org/w/api.php';
    var 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');
    // TODO: Add error handling
    return this.jsonp
               .get(wikiUrl, { search: params })
               .map(request => <string[]> request.json()[1]);
  }
}

构造函数注入 jsonp 服务,并且在调用 WikipediaService 所在的组件里注册 JSONP_PROVIDERS 依赖。

2、搜索参数

维基开放搜索API提供四个查询参数(键值对),分别:searchactionformatcallback;其中 search 为于查询关键词,另外三个都是固定值。

如果我们想查询有关 “Angular” 文章,我们可构造这样一个查询字符串,并调用 jsonp

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

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

如果参数很多时,也可以使用Angular URLSearchParams 辅助类。

var 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');

这次调用 jsonp,它具有两个参数,wikiUrl 和 一个可选对象做为查询参数。

app/wiki/wikipedia.service.ts

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

3、WikiComponent组件

现在,已经有一个可以查询维基百科服务接口,我们转向使用用户输入关键词和显示搜索结果的组件。

import {Component}        from 'angular2/core';
import {JSONP_PROVIDERS}  from 'angular2/http';
import {Observable}       from 'rxjs/Observable';
import {WikipediaService} from './wikipedia.service';
@Component({
  selector: 'my-wiki',
  template: `
    <h1>Wikipedia Demo</h1>
    <p><i>Fetches after each keystroke</i></p>
    <input #term (keyup)="search(term.value)"/>
    <ul>
      <li *ngFor="let item of items | async">{{item}}</li>
    </ul>
  `,
  providers:[JSONP_PROVIDERS, WikipediaService]
})
export class WikiComponent {
  constructor (private _wikipediaService: WikipediaService) {}
  items: Observable<string[]>;
  search (term: string) {
    this.items = this._wikipediaService.search(term);
  }
}

组件元数据 providers 数组指定了 JSONP_PROVIDERS 用于支持 Jsonp 服务,之前就说过 WikipediaService 里并没有任何注册依赖。

组件提供一个 <input> 搜索框,收集搜索关键词,同时绑定 keyup 事件,每于输入都会调用 search(term) 方法。search(term) 方法委托 WikipediaService 返回一个Observable字符串列表,而不是像我们之前在 HeroListComponent 组件里看到一样返回一个可被订阅的Observable,并在模板里通过 ngFor 异步管道处理订阅。

五、优化

维基百科搜索对服务器的请求太频繁了,这并不是我们最想要。

1、等待用户停止录入

我们绑定的是 keyup,意味者每一次录入都会发送请求,应用程序应该在用户停止录入时才发送请求:

wiki-2

2、搜索词变化时才发送请求

假设用户在搜索框输入 “Angular”,并暂停一段时间后,应用程序发送请求。然后用户删去三个字母 “lar” 又立即重新录入回去,这时搜索词依然是 “Angular”,应用程序不应该发送请求。

3、处理过期响应

用户录入 angular、停顿、清除搜索框、重新录入 http,应用程序发起 angular 和 http 两次搜索请求。但是我们无法知道哪一次会优先得到响应,有可能是 http 请求优先到达,这时后到达的 angular 覆盖了 http 列表。

当有多个请求时,响应也应该是按顺序响应,像上面如果 angular 是后到达则应该抛弃响应处理。

六、更多Observables玩法

我们可以通过几个漂亮的 observable 操作符来解决这些问题和优化我们的应用程序用户体验,重新更新 WikipediaService

import {Component}        from 'angular2/core';
import {JSONP_PROVIDERS}  from 'angular2/http';
import {Observable}       from 'rxjs/Observable';
import {Subject}          from 'rxjs/Subject';
import {WikipediaService} from './wikipedia.service';
@Component({
  selector: 'my-wiki-smart',
  template: `
    <h1>Smarter Wikipedia Demo</h1>
    <p><i>Fetches when typing stops</i></p>
    <input #term (keyup)="search(term.value)"/>
    <ul>
      <li *ngFor="let item of items | async">{{item}}</li>
    </ul>
  `,
  providers:[JSONP_PROVIDERS, WikipediaService]
})
export class WikiSmartComponent {
  constructor (private _wikipediaService: WikipediaService) { }
  private _searchTermStream = new Subject<string>();
  search(term:string) { this._searchTermStream.next(term); }
  items:Observable<string[]> = this._searchTermStream
    .debounceTime(300)
    .distinctUntilChanged()
    .switchMap((term:string) => this._wikipediaService.search(term));
}

这里只是更新组件类,元数据和模板都没有任何改动。

1、创建关键词流

搜索框绑定 keyup 事件,所以每次按键都会调用组件 search 方法。

我们使用 Subject 把这些事件变成一个 observable 关键词流,所以需要先加载 RxJS 模块:

import {Subject}          from 'rxjs/Subject';

每个关键词都是一个字符串,所以定义一个名为 _searchTermStream 类型为 Subject<string> 私有变量,每次调用 search 方法都会把搜索框值通过 next 方法添加到流当中。

private _searchTermStream = new Subject<string>();

search(term:string) { this._searchTermStream.next(term); }

2、监听关键词

之前我们直接通过Service类请求数据,然后在模块中显示。现在我们改成监听流,并在访问Service请求数据前做一些操作。

items:Observable<string[]> = this._searchTermStream
  .debounceTime(300)
  .distinctUntilChanged()
  .switchMap((term:string) => this._wikipediaService.search(term));
  • debounceTime 等待用户输入至少300毫秒时才发送请求。
  • distinctUntilChanged 只有变更搜索值才会发送请求。
  • switchMap 把Observable产生的结果转换成多个Observable,然后把它扁平化成一个Observable,然依次按顺序把结果给订阅者,虽然会按顺序请求,但是如果请求响应的最新结果早于旧请求结果时,会自动舍弃旧结果的所有操作。

七、总结

Http Client 是一个非常核心库,但是光只是一个HTTP请求库并没有什么特殊的;重点是配合 RxJS,让我们在编写异步操作时使用同步的代码风格,我们也可以叫:响应式编程。