在进行先后端分离的开发中,跨域是一个不得不解决的问题。如下基于 Vue-Resource、PHP 及 Nginx 介绍跨域问题及其解决方案。php
Nginx 中的配置只是简单的指向 PHP 代码的所在目录:html
server{ listen 80; server_name localhost; root /mnt/apps; index index.php index.html index.htm; location / { index index.php index.html; } location ~ \.php$ { fastcgi_pass localhost:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }
PHP 只是输出一个 JSON 数据,代码以下:前端
// api.php <?php header('Content-type : application/json'); $response = [ 'key' => 'value' ]; echo json_encode($response);
Vue-Resource 为调用该接口,试图获取其中的数据:vue
this.$http.get('http://localhost:8088/api.php').then(res => { console.log(res); })
首先,咱们在 Postman 中进行测试:laravel
能够看到,这一接口是可以返回预期值的。json
但当咱们刷新 Vue 页面时,控制台中却没有输出想要的值,而是抛出了错误:segmentfault
XMLHttpRequest cannot load http://localhost:8088/api.php. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:8080' is therefore not allowed access.
跨域问题基于浏览器的同源策略,简而言之,就是脚本不能调用来自不一样域名、不一样协议、不一样端口的资源。如上,来自 localhost:8080
的 JavaScript 代码试图获取 localhost:8088
的 PHP 返回值(视为资源),便违背了同源策略,从而引起跨域问题。后端
有关同源策略的更多信息,能够参考知乎的这篇讨论:对于浏览器的同源策略你是怎样理解的呢?。api
同源策略带来两个问题:跨域
如下提供三种方案以供参考。
PS:有同窗可能会有疑问,为何在 Postman 中不会有跨域问题呢?注意,跨域问题针对的是脚本对资源的访问限制,而 Postman 自己基于客户端代码,是 C/S 架构,天然不会有此问题。这就像是使用 curl
调用接口也不会受同源策略影响同样。
当咱们谈及获取非本域资源时,能够发现并非全部类型的资源都受同源策略限制的,好比图片和 JavaScript、CSS 等。
这也使得咱们能够转换思路,采用一种取巧的方式得到那些被同源策略拒绝的资源。好比,服务端动态地把数据放在 JavaScript 中,在前端请求时,将动态生成的 JavaScript 文件返回,文件中的内容包含相应的数据。
但是,单纯的在 JavaScript 文件中包含数据是不能被前端获取的。由于经过 JavaScript 代码是不能读取文件中的内容的。因此,咱们的思路还须要转换一下。考虑如下场景:
<!DOCTYPE html> <html> <head> <title></title> </head> <body> <script> function handle(data){ console.log(data); } </script> <script src="http://localhost:8088/remote.js"></script> </body> </html>
引用的远程 JavaScript 文件为:
<!-- http://localhost:8088/remote.js --> <script> var json = { key: 'value' } handle(json); </script>
这样一来,经过这种方式,咱们便在本地获取到了远程的数据。期间存在跨域,但没有违背同源策略。
也就是说,只要后台可以根据前台的请求,动态的生成一个调用特殊函数的 JavaScript 文件就能够了。具体流程以下:
在这其中,有几个问题须要解决:
handle
函数的名字?先来看第一个问题。事实上,在 JavaScript 代码中是不能直接向后台索要 JavaScript 文件的。除非使用 DOM 操做建立一个 script
标签,再将请求地址经过 src
填充到该标签之中。可即便是这样,让后台动态生成 JavaScript 文件的方案仍是不合适,这无疑增长了后台的负担。
不生成文件,如何实现函数调用和调用时传参呢?
在 JavaScript 中,咱们可使用 eval()
使得字符串具备特殊意义。如 eval("handle('data')")
可使得中间的字符串变为 handle
函数的执行。这样一来,后台便没必要再生成 JavaScript 文件,而只用发送字符串,再由前台经过 eval
处理便可。
第二个问题相对容易解决,咱们都知道,在进行 API 请求时,不管是 GET 仍是 POST,均可以携带参数。也就是说,咱们只须要把想要后台使用的函数名经过参数传递便可,如 http://localhost:8088/api.php?callback=handle
。后台接收到请求后,取得 callback
参数便可得到所需的函数名。
这样一来,整个流程就变为了:
采用原生方法实现时,咱们须要准备一个接收函数(如 handle),以及在收到数据后使用 eval
将其包裹。这一过程实际上引入了不少与业务无关的代码。
借助 Vue-Resource 或 jQuery 等库,咱们能够轻松地实现 jsonp:
将原先的 JavaScript 代码改变为:
this.$http.jsonp('http://localhost:8088/api.php').then(res => { console.log(res); })
并将 PHP 代码修改成:
<?php header('Content-type : application/json'); $data = [ 'key' => 'value' ]; $callback = $_GET['callback']; $json = json_encode($data); echo $callback.'('.$json.')';
此时,咱们能够在看到以下结果:
如此,咱们便获得了想要的数据。
注意这里的请求 URL,Vue-Resource 自动帮咱们加上了 callback
参数,即接收函数的名字。
若是咱们想要自行指定接收参数的名字,或者在请求时添加额外的参数,可使用以下方式:
this.$http.jsonp('http://localhost:8088/api.php', { params: { param1: 1 }, jsonp: 'callback' }).then(res => { console.log(res); })
在浏览器的控制台中,咱们能够找到这次请求的网络传输过程:
能够看到,这里实际是发起了一次 GET 请求。
此外,由上图可知,使用 jsonp 的方式,Cookie 值也是能够成功传递的。
可是,这种作法实际上是存在一些问题。由于须要适配 jsonp 的需求,返回值实际上变成了接收函数与实际数据的字符串拼接:
这确实是解决了跨域的需求,但对于不跨域的请求,就须要另行处理了。
另外,因为自己不能指定请求类型,采用 jsonp 难以进行 RESTful 风格的 API 请求(除非使用请求头方法覆盖),于是对于愈发流行的 API 请求范式,这一方式也显得有些过期。
同源策略的目的是为了安全性,那么有没有一种方法,使得客户端和服务器之间彼此信任,从而赞成对方跨域访问呢?
如下讨论经过添加响应头的方式解决跨域问题。
首先咱们将 PHP 和 JavaScript 代码还原:
header('Content-type : application/json'); $data = [ 'key' => 'value' ]; echo json_encode($data);
this.$http.get('http://localhost:8088/api.php').then(res => { console.log(res); })
这时浏览器又会提示出现跨域问题。
接着咱们为 PHP 代码增长一条语句:
header('Access-Control-Allow-Origin : *');
此时即可以获得指望的返回值了。能够注意到,咱们此时并无修改前台代码。
下面,咱们将代码稍做修改,将前端请求方式改成 PUT:
this.$http.put('http://localhost:8088/api.php').then(res => { console.log(res); })
这时,浏览器又抛出了跨域错误。为了解决这一问题,咱们还须要给 PHP 代码加入一个响应头:
header('Access-Control-Allow-Methods : PUT');
当加入这一响应头后,浏览器依然会抛出跨域问题,只不过这一问题如今变成了:
XMLHttpRequest cannot load http://localhost:8088/api.php. Request header field Content-Type is not allowed by Access-Control-Allow-Headers in preflight response.
提示咱们,Content-type
这一请求头不被容许。
针对这一问题,咱们按照提示,在 PHP 代码再增长一个响应头:
header('Access-Control-Allow-Headers : Content-type');
此时,跨域问题便解决了。注意,这里不能使用 *
号,所需增长的内容须要根据实际状况。
为何采用 GET 请求的时候,不须要这一条代码呢?这是因为 HEAD、GET、POST 类型的请求为简单请求,只需增长 Access-Control-Allow-Origin: *
便可。但除此以外的请求方式均为复杂请求。在复杂请求发起时,浏览器会首先发送 OPTIONS 类型的请求,询问浏览器是否赞成跨域,以及容许跨域的条件(OPTIONS 无需写入 Access-Control-Allow-Methods
中),这一步被称为预请求(preflight request)。
就上面的状况而言,由于服务端没有设置是否容许复杂请求及具备特殊 Content-type
头的请求进行跨域,因此请求被拦截了下来。
经过浏览器的控制台咱们能够更清晰地看到这些过程:
首先发送了一次 OPTIONS 请求:
而后才是真正的 PUT 请求:
同理,当使用 DELETE 等其余复杂请求时,只需修改响应头便可。
关于这一点,这篇博客有着很是详细的介绍:CORS 跨域 access-control-allow-headers 的问题 - CSDN。
在 Vue-Resource 中,提供了这两种参数。
其中,前者能够将 PUT、DELETE 和 PATCH 请求转换为 POST,并经过 X-HTTP-Method-Override
请求头标识真实的请求类型。这种作法能够兼容一些旧版本的协议。
然后者能够将请求的 body
使用 application/x-www-form-urlencoded
编码。有关 x-www-form-urlencoded
能够参考 form-data、x-www-form-urlencoded、raw、binary的区别。
经过浏览器的控制台,或是将后台代码改成:
header('Access-Control-Allow-Origin : *'); header('Access-Control-Allow-Methods : PUT'); header('Access-Control-Allow-Headers : Content-type'); header('Content-typ : application/json'); $data = [ 'key' => 'value' ]; $data = $_COOKIE; echo json_encode($data);
咱们能够检测到,此次的请求并无携带 Cookie 进行发送。而没有 Cookie 会使得大量需求没法实现。那么该如何解决这一问题呢?
此时,咱们须要对前端和后台代码同时进行修改:
修改 JavaScript 代码为:
this.$http.put('http://localhost:8088/api.php',{}, { credentials: true, }).then(res => { console.log(res); })
注意,在 credentials: true
的前面还有一个 {}
,这是由于对于 PUT、POST、DELETE 等能够携带 body 体参数的请求而言,其第二个参数为 body 参数项,其余配置须要放在第三个参数中。
对于 GET 请求,咱们则须要把它放到第二个参数中:
this.$http.get('http://localhost:8088/api.php',{ credentials: true, }).then(res => { console.log(res); })
在 PHP 代码中,咱们须要增长一个响应头:
header('Access-Control-Allow-Credentials: true');
此时,刷新浏览器能够发现又抛出了一个错误:
XMLHttpRequest cannot load http://localhost:8088/api.php. Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. Origin 'http://localhost:8080' is therefore not allowed access. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.
这提示咱们,当使用 credentials
时,Access-Control-Allow-Origin
的响应头不能设置为 *
。此时,咱们须要把本来的后台设置改成:
$origin = $_SERVER['HTTP_ORIGIN']; header('Access-Control-Allow-Origin : '.$origin);
即前端域名为何,后台便容许什么跨域。
这样一来,咱们便又能够正常的获得 Cookie 值了:
经过这种方式依然须要咱们增长不少与业务无关的代码。固然了,咱们能够经过在后台框架中增长中间件的方式为响应结果统一添加响应头。虽然这种方式确实很方便,但当后台返回的状态码不是 2xx,即后台报错或进行重定向时,浏览器收到的结果依然会变成跨域错误。这种状况使得咱们没法在测试时准确的知道哪里出现了问题。
为了更进一步的改进跨域方案,咱们试着将增长响应头的工做交给服务器来作,如 Apache 或 Nginx。
在修改 Nginx 配置前,咱们先将 PHP 代码还原:
header('Content-type : application/json'); $data = $_COOKIE; echo json_encode($data);
注意,JavaScript 代码与以前相同。如需考虑 Cookie 问题,仍是须要在异步请求中中加入 credentials: true
的。
而后,咱们修改 Nginx 配置为:
server{ listen 80; server_name localhost; root /mnt/apps; index index.php index.html index.htm; location / { index index.php index.html; } location ~ \.php$ { add_header 'Access-Control-Allow-Origin' "$http_origin"; add_header 'Access-Control-Allow-Credentials' "true"; add_header 'Access-Control-Allow-Methods' "PUT, DELETE"; add_header 'Access-Control-Allow-Headers' "Content-type"; fastcgi_pass localhost:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }
注意,这里主要的代码在于经过 Nginx 为响应附加响应头:
add_header 'Access-Control-Allow-Origin' "$http_origin"; add_header 'Access-Control-Allow-Credentials' "true"; add_header 'Access-Control-Allow-Methods' "PUT, DELETE"; add_header 'Access-Control-Allow-Headers' "Content-type";
而添加的响应头类型均与以前在 PHP 代码中的改动一致。
如此,也能够解决跨域问题。
在进行前端开发时,极可能会使用字体图标,当咱们试图获取非本域的如 iconfont.eot
等资源时,极可能也会在控制台中看到跨域拒绝的报错信息。
诸如此类的问题均可以使用添加响应头来实现。只不过对于这种静态资源文件,因为它们都是 GET 请求,因此咱们只须要在服务器中添加 Access-Control-Allow-Origin: *
。如在 Nginx 中:
location ~* \.(eot|otf|ttf|woff|svg)$ { add_header 'Access-Control-Allow-Origin' "*"; }
这样,在前端请求这些后缀名的资源文件时,便不会出现报错信息了。
最近在使用 Laravel 时,发现了一个诡异的现象:当在 Laravel 中使用中间件进行跨域时(代码以下):
class CORSProtection { public function handle($request, Closure $next) { $response = $next($request); if(isset($_SERVER['HTTP_ORIGIN'])){ $origin = $_SERVER['HTTP_ORIGIN']; $response->header('Access-Control-Allow-Origin', $origin); } $response->header('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With'); $response->header('Access-Control-Allow-Credentials', 'true'); $response->header('Access-Control-Allow-Methods', 'POST, PUT, DELETE, OPTIONS'); return $response; } }
来自前端的跨域请求:GET 和不带参数的 POST 均可以正常发送。而一旦 POST 中携带了参数,浏览器就会输出跨域错误。
经过查看浏览器的网络,发如今 POST 请求前确实发送了一次 OPTIONS 请求,但该请求的响应并无按咱们以前所说的携带上容许跨域的信息头。
经过查阅资料发现,Laravel 会对 OPTIONS 请求自动返回 200 状态码而无视中间件或其余形式的响应头附加。详见参考:关于 Laravel 下 Cors 跨域 POST 请求的一种实现方法。
此时,咱们须要在路由中强制加入对 OPTIONS 类请求的响应,以使得 OPTIONS 探测请求可以正确的响应咱们想要的跨域容许信息:
Route::options('{any}', function ($any) { return response('ok'); })->middleware('cors');
因为 Laravel 中彷佛没有缺省路由,这里须要根据请求 URL 的层级添加不一样的路由。
可是,为何 GET(不管带参与否)以及不带参的 POST 请求都没有出现这一问题呢?还记得前面提到的复杂请求和简单请求吗?GET 和 POST 虽然都是简单请求,但当 POST 携带参数时,因为大多数前端 HTTP 请求框架的默认 POST 带参请求头 Content-Type 都是 application/json
,不属于简单请求的类型,于是触发了 OPTIONS 探测。而 Laravel 会默认对 OPTIONS 请求返回 200 状态,而不是携带咱们定义好的那些响应头,因而就出现了上述诡异的状况。
这里要说明的是,使用 Nginx 方案是不会遇到这一问题的。此外,在请求中添加请求头,强制 Content-Type 为 x-www-form-urlencoded 或其余简单请求类型时(如 Vue-Resource 中使用 emulateJSON: true),也能够跨过这一问题。