今天是过完春节以后的第二周啦,而我好像终于回到正常工作的状态了呢,因为突然间就对工作产生了厌倦的情绪,Bug就像无底洞一样吞噬着我的脑细胞。人类就像一颗螺丝钉一样被固定在整部社会机器上,除了要让自己看起来像个正常人一样,还要拼命地让所有人都像个正常人一样。过年刚经历过被催婚的我,面对全人类近乎标准的“幸福”定义,大概就是我此刻这种状态。其实,除了想自己定义“幸福”以外,我还想自己定义“问题”,因为,这样就不会再有“Bug”了。言归正传,今天我想说的是前端跨域这个话题,相信读完这篇文章,你就会明白,这个世界上太多太多的问题,都和你毫无瓜葛。

故事缘起

年前被安排去做一个GPS相关的需求,需要通过百度地图API来计算预计到达时间,这并不是一个有难点的需求,对吧?就在博主为此而幸灾乐祸的时候,一个非常醒目的错误出现在Chrome的控制台中,相信大家都见过无数次啦,大概是说我们的请求受到浏览器的同源策略的限制。那么,第一个问题,什么是同源策略呢?我们知道,一个URL通常有以下几部分组成,即协议、域名、端口和请求资源。由此我们就可以引申出同源的概念,当协议、域名和端口都相同时,就认为它们是在同一个域下,即它们同源。相反地,当协议、域名和端口中任意一个都不相同时,就认为它们在不同域下,此时就发生了跨域。按照排列组合,我们可以有以下常见的跨域场景:

URL 说明 是否允许跨域
www.abc.com/a.js vs www.abc.com/b.js 相同域名下的不同资源 允许
www.abc.com/1/a.js vs www.abc.com/2/b.js 相同域名下的不同路径 允许
www.abc.com:8080/a.js vs www.abc.com:8081/b.js 相同域名下的不同端口 不允许
http://www.abc.com vs https://www.abc.com 相同域名采用不同协议 不允许
http://www.abc.com vs http://wtf.abc.com 相同域名下的不同子域 不允许
http://www.abc.com vs http://www.xyz.com 两个完全不同的域名 不允许
http://192.168.100.101 va http://www.wtf.com 域名及其对应的IP地址 不允许

那么,我们就不仅要问啦,现在微服务啊、RESTful啊这些概念非常流行,在我们实际的工作中,调用第三方的WebAPI甚至WebService,这难道不是非常合理的场景吗?前端的Ajax,即XMLHttpRequest,和我们平时用到的RestSharp、HttpClient、OkHttp等类似,都可以发起一个Http请求,怎么在客户端里用的好好的东西,到了前端这里就突然出来一个“跨域”的概念呢?这是因为从原理上来说,这些客户端都是受信的“用户”(好吧,假装是被信任的),而浏览器的环境则是一个“开放”的环境。

URI_Syntax_Diagram
URI_Syntax_Diagram

举一个例子,你在家的时候,可以随意地把手插进自己的口袋,因为这是你的私有环境。可是当你在公共环境中时,你是不允许把手插进别人口袋的。所以,浏览器有“跨域”限制,本质上是为了保护用户的数据安全,避免危险地跨域行为。试想,没有跨域的话,我们带上Cookie就可以为所欲为了,不是吗?实际上,同源限制和JavaScript没有一丁点关系,因为它是W3C中的内容,是浏览器厂商要这样做的,我们的请求其实是被发出去了,而它的响应则被浏览器给拦截了,所以我们在控制台中看到“同源策略限制”的错误。

喜闻乐见的跨域拦截
喜闻乐见的跨域拦截

十八般武艺

好了,既然现在浏览器有这个限制,那为了客户着想,我们还是要去解决这个问题(对吧?),虽然我至今想不明白,适配浏览器为什么会成为我们的工作之一[doge]。打开Google搜索“前端跨域”,于是发现了解决跨域问题的各种方案,这里选取最具代表性的JSONP和CORS。

JSONP

首先,我们来说说JSONP,什么是JSONP呢?我们知道,通常RESTful接口返回的都是JSON,而JSONP返回的是一段可以执行的JavaScript代码,我们所需要的数据就被“包裹”在这段代码中,这就是JSONP,即JSON Padding的得名由来。在实际应用中,服务的提供方会根据调用方传入的回调函数(callback)来组织返回数据,譬如callback({“name”:”tom”,“gender”:”male”})。这就说到一个点,并不是所有的API接口在调用的时候出现跨域问题,都可以通过JSONP的方式来解决,因为它需要后端来配合组织返回数据。这里我们以“不蒜子”这个静态博客中使用最多的访问量统计工具为例,通过查看页面源代码,我们了解到它是通过JSONP来返回数据的。为什么它要用这种方式来返回数据呢?其实,我们仔细想想就能明白其中的缘由,因为像Hexo、Jekyll这种静态博客大多都是没有后端服务支持的,所以,它要访问“不蒜子”的统计服务,就必然会存在跨域的问题啊!那怎么解决这个问题呢?当然是选择JSONP啦!这里我们以Postman调用不蒜子接口为例,可以发现它的返回值是下面这个样子:

在Postman中调用
在Postman中调用"不蒜子"接口

博主计划在接下来的时间里,迁移不蒜子的统计数据到LeanCloud上,届时博主会使用最喜欢的Python,来抓取这些访问量数据,因为JSONP返回的都不是JSON数据,因此再处理这些数据的时候,需要用正则来匹配这些结果。为什么在前端领域没有这些问题呢,因为JSONP返回的是世界上最“任性”的语言——JavaScript,当然,这些会是下一篇甚至下下一篇里的内容啦。

CORS

好了,下面我们说说CORS这种方案。CORS,即跨域资源共享,是一种利用HTTP头部信息访问不同域下的资源的机制。我们在前面提到过,发生跨域访问时,其实请求已经发出去了,但响应则被浏览器给拦截住了。那么,CORS说白了就是它可以通过HTTP头部信息,告诉浏览器来自哪些域的请求可以被允许,来自哪些域的请求应该被禁止。如果说JSONP多少带着点“hack”的意味儿,那么CORS就可以说是被官方认可的跨域解决方案啦!这种方案需要启用新的HTTP头部字段,具体可以参考这里

按照定义,浏览器会将CORS请求分为简单请求非简单请求两类。对于简单请求,浏览器会对请求的头部进行“魔改”,即增加一个Origin字段,这样只要后端接口支持CORS跨域,就可以接收这些跨域请求,并做出回应,即在响应的头部信息中返回Access-Control-Allow-Origin等字段。而对于非简单请求,通常会先发出一个OPTIONS的“预检请求”,只有这个验证过程通过以后,主请求才会被发起。那么浏览器是怎么验证请求是否通过的呢?答案就是:检查Origin字段是否包含在Access-Control-Allow-Origin中。当验证不通过时,浏览器就会输出同源策略限制的错误。这就是CORS,浏览器和服务端分别通过响应、请求的HTTP头部信息来“商量”要不要跨域。

没有银弹

说了这么多关于“跨域”的话题,其实博主想说的是,没有银弹。这是一位前辈高人,曾经对博主反复说过的话。现在我们来看JSONP,会发现它本质上是利用了浏览器的“漏洞”。为什么这样说呢?因为在浏览器中,所有具备src属性的HTML都是可以跨域的,譬如script、img、iframe、link这四个标签,我们赖以生存的CDN加速、图床、插件等等都是基于这一“漏洞”的产物。所以,很多人问为什么$.ajax可以跨域,但原生的XMLHttpRequest则不可以呢?因为jQuery实际上把JSONP做成了一种语法糖,这就就会给人一种ajax可以跨域的错觉。

JSONP?其实就是JS

JSONP实际上返回的是可以执行的JavaScript,即text/javascript,它和我们所使用的大多数JavaScript并无区别,所以,你可以想到,当我们把一个远程地址赋值给script标签的src属性时,它和我们引用CDN上的医院文件并无区别,这正是JSONP的秘密所在,显然它只支持Get方式,当我们想要支持更多方式的时候,我们需要的是CORS,一起来看下面这段代码,我们首先来写一个简单的API接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// GET api/user/5?callback=
[HttpGet("{id}")]
public IActionResult Get(string id, string callback)
{
var userInfo = UserInfoService.Find(x => x.UserId == id);
if (userInfo == null) return NotFound();
if (string.IsNullOrEmpty(callback))
{
//返回JSON
Response.ContentType = "application/json";
return Json(userInfo);
}
else
{
//返回JSPNP
Response.ContentType = "application/javascript";
return Content($"{callback}({JsonConvert.SerializeObject(userInfo)})");
}
}

OK,写完这个接口以后,我们首先来尝试在前端页面中调用这个接口,为了尽可能地减少依赖,我们这里用最新Fetch API来代替$.ajax(),毕竟现在都是2019年了呢,Github和Bootstrap相继宣布从代码中移除jQuery。大家都知道,原生的xhr和Date对象一样,简直难用得要命,而这一切在新的Fetch API下,会变得非常简单:

1
2
3
4
5
6
7
8
9
10
//基于Fecth API调用JSONP
showUserByFetch:function(){
fetch("https://localhost:5001/api/user/1")
.then(function(response) {
return response.json();
})
.then(function(user) {
showUser(user);
});
}

果然,就算使用最新的Fetch API,浏览器还是会因为同源限制策略而拦截我们的请求

浏览器中再次出现同源限制错误
浏览器中再次出现同源限制错误

那么,试试用JSONP的思路来解决这个问题。注意到,为了兼容JSONP方式调用,我们在API接口中增加了一个callback参数,这个参数实际上就是预先在客户端中定义好的方法的名字啦!既然JSONP返回的是可执行的JavaScript,那么我们在页面里增加一个Script标签好了:

1
<script src="https://localhost:5001/api/user/1?callback=showUser"></script>

其中,showUser是一个预先定义好的JS函数,其作用是输出用户信息到页面上:

1
2
3
4
5
//展示用户信息
function showUser(user){
var result = document.getElementById('jsonp-result');
result.innerText = '用户ID:' + user.uid + ", 姓名:" + user.name + ', 性别:' + user.gender;
}

现在,我们可以注意到,在控制台中输出了我们期望的结果,这说明页面中定义的showUser()方法确实被执行了,所以,到这里我们可以对JSONP做一个简单总结:JSONP是一种利用script标签实现跨域的方案,它需要对后端接口进行适当改造以返回可以执行的JavaScript,客户端需要事先定义好接收数据的方法,两者通过callback参数建立起联系,返回类似callback({“name”:”tom”,“gender”:”male”})结构的数据,因此JSONP请求必然且只能是一个GET请求

通过Script标签调用JSONP
通过Script标签调用JSONP

既然通过Script标签可以调用一个JSONP接口,那么我们不妨试试动态创建Script标签,然后你就会发现这两种方式的效果是一样的,都可以调用一个JSONP接口,前提是JS中已经存在showUser()方法:

1
2
3
4
5
6
7
//动态创建scipt调用JSONP
showUserByDynamic:function(){
var self = this;
var script = document.createElement("script");
script.src = "https://localhost:5001/api/user/1?callback=showUser";
document.body.appendChild(script);
},

事实上,jQuery中针对JSONP的支持正是基于这种原理,虽然jQuery的时代终将过去,可我相信这些背后的原理永远不会过时。顺着这个思路,我们不妨来看看jQuery中是如何实现JSONP的,以下代码可以在这里找到:

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
26
27
28
29
30
31
// Bind script tag hack transport
jQuery.ajaxTransport("script",
function(s) {

// This transport only deals with cross domain or forced-by-attrs requests
if (s.crossDomain || s.scriptAttrs) {
var script, callback;
return {
send: function(_, complete) {
script = jQuery("<script>").attr(s.scriptAttrs || {}).prop({
charset: s.scriptCharset,
src: s.url
}).on("load error", callback = function(evt) {
script.remove();
callback = null;
if (evt) {
complete(evt.type === "error" ? 404 : 200, evt.type);
}
});

// Use native DOM manipulation to avoid our domManip AJAX trickery
document.head.appendChild(script[0]);
},
abort: function() {
if (callback) {
callback();
}
}
};
}
});

可以注意到,它和我们这里的思路一致,即动态创建一个script标签,然后设置其src属性为目标地址,当其加载完成或者加载失败时,就会从页面的DOM节点中删除该标签,因为数据已经通过指定的callback处理过了。jQuery甚至可以替我们生成对应的callback函数,例如,在这里我们可以这样使用jQuery来实现JSONP跨域,具体使用细节这里不再深究:

1
2
3
4
5
6
7
8
9
10
11
12
13
//基于$.ajax()调用JSONP
showUserByAjax:function(){
$.ajax({
type: "get",
url: "http://localhost:5000/api/user/1",
dataType: "jsonp",
jsonp: "callback",
data: "",
success: function (user) {
showUser(user);
}
});
},

CORS,跨域新标准?

相对JSONP来说,CORS实现起来就非常简单啦,因为主流的Web框架中几乎都提供了CORS的支持,因为CORS可以实现除了GET以外的譬如POST、PUT等请求,所以,它比JSONP这种”Hack“的方式有更为广阔的适用性,而且随着Web标准化的不断推荐,目前CORS可以说是官方主推的跨域方案。这里我们以.NET Core为例来讲解CORS跨域。

CORS,即同源资源共享,其实早在ASP.NET时代,这一机制就已经得到了支持,现在我们以.NET Core来讲,无非是希望大家放下历史包袱,在跨平台的新道路上轻装上阵。好了,在.NET Core中我们有两种CORS方案,一种是在Startup类中以全局配置的方式注入到整个中间件管道中,一种是以特性的方式在更小的粒度上控制CORS。这其实和之前配置路由的思路相近,即我们可以配置全局的路由模板,同样可以在Controller和Action级别上定义路由。在这里,我们先定义两种CORS策略,AllowAll和AllowOne,并以此来测试CORS实际的使用效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//CORS策略:简单粗暴一刀流
services.AddCors(opt=>{
opt.AddPolicy("AllowAll", builder => {
builder.AllowAnyOrigin();
builder.AllowAnyHeader();
builder.AllowAnyMethod();
});
});

//CORS策略:允许指定域
services.AddCors(opt=>{
opt.AddPolicy("AllowOne", builder => {
builder.WithOrigins("http://localhost:8888")
.AllowAnyHeader()
.AllowAnyMethod()
.WithExposedHeaders("X-ASP-NET-Core","X-UserName")
.AllowCredentials();
});
});

可以注意到,在全局范围内应用AllowAll以后,我们的后端接口将支持来自任意域/端口的跨域访问,这意味着我们之前必须使用JSONP来跨域的地方,现在都可以直接发起跨域请求。到底是不是和我们想得一样呢?答案啊,那必须是肯定的啊!

1
2
3
4
5
6
7
[EnableCors("AllowOne")]
[Route("api/[controller]")]
[ApiController]
public class UserController:Controller
{
//...
}

好了,现在我们来测试在UserController上应用局部的CORS请求,在这个实例中,我们指定只有来自localhost:8888的请求可以跨域,为此博主这里用Python临时开了一个服务器,本文中的前端页面,实际上就是运行在这个服务器上的。你知道我想说什么,“人生苦短,我用Python”。因为我们这里返回的是application/json,所以它是一个非简单请求,这里复习一下简单请求与非简单请求。

简单请求

根据MDN中关于CORS的定义,若请求满足所有下述条件,则该请求可视为“简单请求”,简单请求意味着不会触发CORS 预检请求

  • 使用下列方法之一:GET、HEAD、POST。
  • Fetch 规范定义了对 CORS 安全的首部字段集合,不得人为设置该集合之外的其他首部字段。该集合为:Accept、Accept-Language、Content-Language、Content-Type (需要注意额外的限制)、DPR、Downlink、Save-Data、Viewport-Width、Width。
  • Content-Type 的值仅限于下列三者之一:text/plain、multipart/form-data、application/x-www-form-urlencoded。
MDN中对简单请求的图解
MDN中对简单请求的图解

非简单请求

非简单请求和简单请求相反,即不满足简单请求中任一条件的请求都被成为非简单请求。非简单请求,相对简单请求多了一次CORS 预检请求。其过程是,首先由浏览器自动发起一个OPTION请求,该请求中携带HTTP头部字段Origin。在本例中,前端页面部署在http://localhost:8888服务器上,所以,它的Origin字段即为http://localhost:8888。接下来,服务端会返回Access-Control-Allow-Origin/Access-Control-Allow-Headers/Access-Control-Allow-Methods等字段,它对应我们后端定义的AllowOne,注意到这里我们有两个自定义字段X-ASP-NET-Core和X-UserName。在通过预检以后,我们在发起正式请求(本例中为GET请求)的时候,设置后端允许的源,即http://localhost:8888,这样就可以实现基于CORS的跨域请求啦!

MDN中对非简单请求的图解
MDN中对非简单请求的图解

所以,我们可以注意到,这里会有一个OPTION请求,即“预检请求”。对于AllowOne这个CORS策略而言,它允许来自localhost:8888的跨域请求,允许的请求方法有GET、PUT、POST和OPTION,客户端必须携带一个自定义HTTP头:X-ASP-NET-Core。当这三个条件满足时,即表示通过“预检”。此时,服务端会返回Access-Control-Allow-Origin/Access-Control-Allow-Methods/Access-Control-Allow-Headers等字段。接下来,浏览器发起的正式请求会带上这些字段,并返回我们所需要的JSON数据,这就是CORS跨域的实际过程。

OPTION预检请求
OPTION预检请求

本文小结

这篇文章主要梳理了目前前端跨域的两种主流方案(事实上,在奇葩的前端领域里,最不缺的就是解决方案),即JSONP和CORS。其中,JSONP本质上是返回可以执行的JS,其基本套路是callback({“foo”:”bar”}),利用了HTML中含src的属性天生具备跨域能力的“漏洞”,是一种相对”hack”的方案,要求预先定义好callback,需要改造后端接口,仅支持最简单的GET请求。而CORS,是比较“官方”的跨域解决方案,其原理是利用HTTP头部字段对请求的来源进行检验,CORS支持除GET以外的请求动词,在使用中间件的情况下,无需修改后端接口,可以在全局或者局部配置CORS跨域策略,对后端开发相对友好。自从接触前端领域,对这个领域里的“黑科技”、“骚操作”吐槽无数次了,不过,前后端分离过程中这些事情还是挺有意思的,对吧?好了,以上就是这篇博客里的全部内容了,欢迎大家吐槽!本文中的示例请从:https://github.com/qinyuanpei/dotnet-sse/blob/master/server/index.html这里来获取,谢谢大家!