在项目管理课的大作业中,负责前端部分代码。前端所使用的框架为Angular,而我们打算在Angular中使用豆瓣的API获取特定ISBN的书籍的信息。结果就很愉快地遭遇了跨域访问的问题,折腾了比较长的时间。

我们所希望访问的API是https://api.douban.com/v2/book/isbn/${this.ISBN}。可以见到的是直接访问这个API后得到的数据没有什么问题,但是当我们在网页应用中对这个API进行访问时,恐怕就没有那么轻松了。

顺带一提,关于这个API接口的官网豆瓣开发者貌似已经没法访问了的样子,但是幸而有谷歌的网络快照能看到其中的内容。

Angular HTTP Client

作为一个在本次项目中才真正开始应用Angular的人,最开始是按照官网的指引直接使用 HttpClient

1
2
3
4
5
constructor(private http: HttpClient) { }
func() {
this.http.get("https://api.douban.com/v2/book/isbn/7101003044")
.subscribe(res => console.log(res));
}

然后就遭遇了下面这个报错:

1
Failed to load https://api.douban.com/v2/book/isbn/7101003044: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:4200' is therefore not allowed access.

跨域简介

我们首先来介绍一下跨域的概念:

首先说同源策略。同源策略是由网景公司引入浏览器的,目前基本所有所有浏览器都实行这个政策。这个策略是为了防范两个网页之间的cookies等不能混用,以防被恶意窃取信息。这是一个用于隔离潜在恶意文件的重要安全机制。在现在,受限制的行为包括:

  1. Cookie、LocalStorage 和 IndexDB 无法读取。
  2. DOM 无法获得。
  3. AJAX 请求不能发送。

在浏览器中,非同源的页面是没法完成上面的行为的。如果两个页面的协议,端口(如果有指定)和域名都相同,则两个页面具有相同的源。

在我们这个情境下,我希望在我的主机给 api.douban.com 发送一个AJAX请求,这明显是跨域的。所以就出现了上面的报错。

Angular 解决跨域请求访问的方法包括以下几种:

  • 跨域资源共享(Cross-Origin Resource Sharing, CORS)
  • 跨文档消息传递(Cross-document messaging)
  • JSONP
  • WebSocket
  • Angular代理

CORS是目前来说推荐使用而且也用得最多的一种方式,是 HTML5 规范定义的如何跨域访问资源。然而显然它需要服务器做出一定的配置以允许跨站请求,无奈只得作罢。

对于跨文档消息传递,需要我们传递消息之后,页面也有消息返回才能接收到信息,所以也被刨去。WebSocket也是因为同样的理由被刨去。

Angular 代理

在没有服务器端的配合情况下,大概也只能通过Angular-cli代理的方式来来进行访问。这是使用了webpack的devServer的proxy,因此只能在Angular的开发环境下使用。

proxy所做的事情是简单地拿到浏览器的请求,然后把它交给我们所设置的API服务器。

一张来自 https://juristr.com/blog/2016/11/configure-proxy-api-angular-cli/ 的图片

我们可以在package.json的同一目录下新建一个文件proxy.conf.json:

1
2
3
4
5
6
7
{
"/api": {
"target": "http://api.douban.com",
"secure": false
},
"changeOrigin": true
}

然后我们可以修改angular.json添加一个proxyConfig字段,或者使用一个

1
2
3
4
5
6
7
8
9
10
...
"architect": {
...
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "your-application-name:build",
"proxyConfig": "proxy.conf.json"
},
...

或者我们可以直接通过ng serve --proxy ./proxy.conf.json来启动项目。为了使用这个,我们可以在package.json中对npm start的命令做出修改。

但是这样的方式只适用于dev环境而不是production环境,偏偏我还希望在prod环境下部署使用(vendor.js太大了)。没办法最后我只能选择JSONP协议。

Angular JSONP

在前面所说的常用方法CORS中,还有一个限制是,对于一些适配IE或更早期浏览器的网址,并没有设置CORS的方式。对于这些古老的网站,它们给出的解决方案就是JSONP协议。

JSONP是一种跨域数据交互的协议。它的主要想法是,我们有使用其它域的图片、JS、CSS文件的时候,这些并没有构成跨域请求,因为这个行为并非Ajax是没有受到限制。那么我可以通过一些标签的src属性来获得相应的内容就好了。

我们假设接到的数据是JSON格式的。然后我们可以在接到的数据外包一层像是函数调用的东西,让整个内容看起来是一个Javascript的脚本,这样就能获得正确的JSON数据了。

豆瓣的API是支持JSONP协议的,它们的关键字是callback。可以尝试访问https://api.douban.com/v2/book/isbn/7544244261?callback=somefunc

1
;somefunc({"rating":{"max":10,"numRaters":1466,"average":"8.8","min":0},"subtitle":"","author":["[波]显克维奇"],"pubdate":"2009-5","tags":[{"count":816,"name":"历史","title":"历史"},{"count":693,"name":"宗教","title":"宗教"},{"count":504,"name":"小说","title":"小说"},{"count":416,"name":"外国文学","title":"外国文学"},{"count":338,"name":"显克维支","title":"显克维支"},{"count":330,"name":"基督教","title":"基督教"},{"count":234,"name":"波兰","title":"波兰"},{"count":216,"name":"波兰文学","title":"波兰文学"}],"origin_title":"Quo Vadis","image":"https://img3.doubanio.com\/view\/subject\/m\/public\/s3942663.jpg","binding":"平装","translator":["林洪亮"],"catalog":"`","pages":"467","images":{"small":"https://img3.doubanio.com\/view\/subject\/s\/public\/s3942663.jpg","large":"https://img3.doubanio.com\/view\/subject\/l\/public\/s3942663.jpg","medium":"https://img3.doubanio.com\/view\/subject\/m\/public\/s3942663.jpg"},"alt":"https:\/\/book.douban.com\/subject\/3733083\/","id":"3733083","publisher":"南海出版公司","isbn10":"7544244261","isbn13":"9787544244268","title":"你往何处去","url":"https:\/\/api.douban.com\/v2\/book\/3733083","alt_title":"Quo Vadis","author_intro":"显克维奇(1846—1916)1905年诺贝尔文学奖得主,波兰著名作家,在全世界享有巨大的声誉,其代表作《你往何处去》、《十字军骑士》等作品已被译成40多种语言。","summary":"《你往何处去》是闪耀于世界文学长廊的璀璨明珠、历史小说领域的巅峰杰作,作者以史家的视角、文学的手法为我们再现了基督教兴起与罗马帝国瞬间衰落的历史真相。该书在20世纪末的末世悲凉气息中首次出版,甫一问世便奇迹般受到读者热烈欢迎,迅速被翻译成英、德、俄、法等40多种文字。《你往何处去》将一对深情男女置于罗马帝国对基督徒残酷镇压的大背景之中,用小说的笔法入木三分地刻画出保罗、彼得、皇帝尼禄等众多历史人物,以史笔栩栩如生地展现基督教在兴起时期受到世俗力量血腥镇压的历史真相。罗马大火与使徒殉道,既将小说推向了高潮,又深邃地揭示了罗马帝国衰落的历史密码……","series":{"id":"1054","title":"新经典文库·桂冠文丛"},"price":"29.80元"});

可以看到的是,在返回的响应中,原本的JSON数据被用一个somefunc包裹了起来。然后我们只需要实现定义一个somefunc就能处理这个JSON数据了。

之后我又幸运地发现了Angular是有JSONP的支持的。我们可以这样使用:

1
2
3
4
func (isbn: string) {
this.http.jsonp(`https://api.douban.com/v2/book/isbn/${isbn}`, 'callback')
.subscribe(res => console.log(res));
}

Angular的http.jsonp会自动生成一个函数名,然后通过上面所说的方式来获得JSON数据,然后

然而在实际使用的时候,我们会发现这样恐怕还是不行。这个问题还是只出在豆瓣的API上,对于其它支持JSONP的API应该都能使用类似上面的代码。

出错的原因是Angular生成的函数名是类似于__ng_jsonp__.__req0.finished这样的格式。然后我们可以去访问一下豆瓣的API https://api.douban.com/v2/book/isbn/7544244261?callback=__ng_jsonp__.__req0.finished。我们会发现,返回的结果中并没有callback函数的内容,看起来豆瓣这里并没有对特殊字符做处理,这导致了Angular JSONP请求的失效。

一通搜索,但是没有找到改动这个JSONP请求函数名的方法。无奈只好自己实现一个JSONP的服务了。

自己实现的JSONP

这个基本上原理就是上面所说的原理,这里只是贴出代码实现了。

首先是生成一个callback的函数名,并构造url:

1
2
3
const hash_str = Md5.init(isbn);
const callbackName = `jsonp_${hash_str}`;
const url = `${this.apiRoot}/isbn/${isbn}?callback=${callbackName}`;

这里的apiRoothttps://api.douban.com/v2/book。然后构造的方式就是jsonp_<isbn的md5值>,当然这个东西完全可以随意。

然后定义这个函数,我们要怎么处理接收到的数据。因为我写成了Promise的形式,所以就传给resolve变量就好。

1
2
3
window[callbackName] = res => {
resolve(res);
};

然后构建script标签,并挂到document里。

1
2
3
4
5
const scriptElem = window.document.createElement('script');
scriptElem.id = callbackName;
scriptElem.src = url;
scriptElem.type = 'text/javascript';
window.document.body.appendChild(scriptElem);

做一个错误处理(我们这里会出现的错误其实就是isbn不正确的时候会有404啦)

1
2
3
4
5
scriptElem.onerror = () => {
reject(new Error(`failed to get ${url}!`));
clearFunction();
if (timeId) { clearTimeout(timeId); }
};

当然我们还可以做得更好,比如在请求成功或失败之后都移除这个标签,并且通过delete window[callbackName]来删除我们定义的callback之类的。

至此这个问题总算是最后解决了,还是非常曲折的。

完整的代码可以在这里看到。