前言
目前在开发中前后端分离的模式比较普遍,那么跨域问题也就时常会遇到。网上资料都很片面,不全面,以及都没有说为什么这么解决。
本文会通过前端ajax访问java后端接口的场景,分别从浏览器、后端响应头设置、代理服务器apache和nginx配置、调用端反向代理等方面考虑跨域解决方案。从简单请求、非简单请求和带cookie的请求等多种请求方式逐步分析如何规避跨域限制。
内容详情
什么是跨域?狭义的理解跨域是指受到浏览器同源策略限制的一类请求,通常我们说的跨域就是指的这一类请求。当协议、域名(包含子域名)、端口号中任意一个不相同时,都属于不同域。不同域之间相互请求资源,就会受到浏览器的同源策略限制。
同源策略
同源策略是一种约定,由Netscape公司1995年引入浏览器,是浏览器最核心也最基本的安全功能。保证用户信息的安全,防止恶意的网站窃取数据。比较常见的就是XSS、CSFR等攻击。
既然有安全问题,那为什么又要跨域呢? 举个例子,假如公司内部有多个不同的子域,一个是location.company.com ,另一个是app.company.com , 这时想从 app.company.com去访问 location.company.com 的资源就需要跨域。
ajax跨域请求
下面,通过ajax访问不同域的后端java接口的案例来分析,如何规避这种限制。(后面我们把这个案例称为案例一)
- 首先新建spring boot项目A,端口号使用默认的8080,快速开发一个java接口如下
@RestController
@RequestMapping("/getData ")
public class GetDataController {
@GetMapping("/getFirstData")
private ResultBean getFirstData() {
System.out.println("getFirstData success");
return new ResultBean("getFirstData success");
}
}
- 再次新建一个spring boot项目B,端口号设置为8081,编辑前端页面如下:
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Insert title here</title>
<script src="jquery-1.9.1.min.js"></script>
<script type="text/javascript">
function getFirstData(){
$.ajax({
type : "GET",
url:"http://localhost:8080/getData/getFirstData",
success:function(json){
console.log(json);
}
});
}
</script>
</head>
<body>
<a href="#" onclick="getFirstData()">发送getFirstData请求</a>
</body>
</html>
- 然后启动两个项目,浏览器中访问 http://localhost:8080/getData/getFirstData,正常返回json数据{"data":"getFirstData success"},此时说明java接口正常。
- 接着浏览器先访问项目B的页面http://localhost:8081/index.html,点击页面上的a标签,结果浏览器控制台并没有打印出接口预期返回的json数据。
-
查看A项目的控制台,输出了"getFirstData success"。在浏览器开发者模式下查看网络,发现请求的状态为200 ,但是出现了以下错误提示。
结论:跨域并不是请求发不出去,请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。
浏览器端解决跨域
根据上面的结论,我们可以让浏览器不做限制,以chorme浏览器为例, 如果设置为支持跨域模式,只需以下几步。
- 在电脑上新建一个目录,例如:C:\MyChromeDevUserData。这是保存个人信息的目录,是chrome浏览器防止用户使用跨域模式泄露自己的个人信息采用的措施,这里不详细探讨。
- 在属性页面中的目标输入框里加上 --disable-web-security --user-data-dir=C:\MyChromeDevUserData,--user-data-dir的值就是刚才新建的目录。
- 点击应用和确定后关闭属性页面,并打开chrome浏览器。发现有“--disable-web-security”相关的提示,说明chrome能正常跨域工作了。
说明:这种解决方式意义不大,因为需要所有的客户端做改动,并且每个用户使用的浏览器也各不相同,处理的方式也不同。在这里介绍这种解决方式,只是为了更进一步说明,产生跨域访问限制的根源就是浏览器。
script标签解决跨域
其实通过script标签可以跨域请求数据的,如下,在B项目的页面中访问百度的一个接口
效果如下:
以上是在案例一中B项目的域里,通过动态创建script标签,跨域访问百度的接口,并成功获取到了资源。这种请求属于script请求,可见浏览器并不会限制这类请求。详细原因可以查看同源策略文档。
但是这种方式需要服务端提供一种约定,约定请求的参数里面如果包含指定的参数(比如上面的cb参数),会把原来的返回对象先转变成js代码然后返回给浏览器解析,而其中Js代码是函数调用的形式,比如上例中百度接口返回的函数的函数名是cb的值,函数的参数就是原来需要返回的结果对象。
假如没有传递cb参数,请求可以正常访问到数据并返回,但是浏览器判断是script请求,所以仍然会以解析脚本的格式解析返回的结果,后果就是无法解析(Uncaught TypeError)。
通过案例一的请求过程,我们发现ajax的请求类型是XHR(XMLHttpRequest)类型,如下。
注:XHR获取数据的目的是为了持续修改一个加载过的页面,是Ajax设计的底层概念,想了解XHR可以点击这里。*
大胆的猜想一下,是否可以让ajax发出的请求封装成script,然后由script向后端发出请求,这样浏览器就不会做限制了。
那么这也正是接下来要介绍的jsonp的解决思路。
jsonp解决跨域
- 什么是jsonp
- 全称是json padding,请求时通过动态创建一个script,在script中发出请求,通过这种变通的方式让请求资源可以跨域。
- 它不是一个官方协议,是一个约定,约定请求的参数里面如果包含指定的参数(默认是callback),就说明是一个jsonp请求,服务器发现是jsonp请求,就会把原来的返回对象变成js代码。Js代码是函数调用的形式,它的函数名是callback的值,它的函数的参数就是原来需要返回的结果。
- jsonp的实现方式
- 通过例子来说明Jsonp的请求方式,如下
$.ajax({
url:"http://localhost:8080/getData/getSecondData",
dataType:"jsonp",
jsonp:"callback",
success:function(json){
console.log(json);
}
});
-
查看浏览器网络,发现jsonp发出去的请求是script类型。因为动态创建的script标签在发送请求以后会马上被删除,所以在浏览器中无法查看的到,我们可以采用断点查看。
- 当jquery-1.9.1.js中的代码执行完如上的位置时,在页面上动态创建了一个script。如下
<script async="" src="http://localhost:8080/getData/getSecondData? callback=jQuery19102645927304195774_1530774759805&_=1530774759806">
</script>
可以看到jsonp在请求url后面追加了两个参数,callback和一个下划线作为参数名的参数,这个callback就是上面提到的约定参数,而下划线作为参数名的参数值是一个随机数,作用是为了防止请求的结果被缓存了,如果想让结果被缓存可以添加cache:true,如
$.ajax({
url:"http://localhost:8080/getData/getSecondData",
dataType:"jsonp",
jsonp:"callback",
cache:true,
success:function(json){
console.log(json);
}
});
此时后台不做改动的话返回的还是json对象,浏览器把对象当做script对象解析,所以会报错。如下可以看到返回的参数类型。
接下来修改后台代码,给提供接口的controller提供一个切面,返回“callback”,编码如下
@ControllerAdvice
public class JsonpAdvice extends AbstractJsonpResponseBodyAdvice{
public JsonpAdvice(){
super("callback"); //其中参数“callback”可以修改,但是必须与页面请求的回调参数对应
}
}
CORS解决跨域方案
- 引言
回到案例一,来看看跨域所报的错误,大概意思是说请求的资源上没有“Access-Control-Allow-Origin”头信息(此处说的是响应头)。
那么可以从这个地方考虑,在返回资源的时候加上这个头信息。这也就是接下来说的CORS请求的解决思路。
CORS是W3C标准, 全名叫跨域资源共享Cross-origin resource sharing,允许浏览器向跨域服务器发出XMLHttpRequest请求。
- 简单请求跨域
当我们在做跨域请求资源的时候,会发现多了Origin的字段(此字段指定了当前页的域名和端口号,它的值是由浏览器自动获取的,无法通过手动修改) ,如下:
然后在响应的时候,浏览器会判断响应头里面有没有跨域信息,如果没有就会报错。
那我们尝试修改后台代码,在响应头里添加这个信息。
@Bean
public FilterRegistrationBean registerFilter(){
FilterRegistrationBean frBean = new FilterRegistrationBean();
frBean.addUrlPatterns("/*");
frBean.setFilter(new CrosFilter());
return frBean;
}
以下是过滤器实现部分。
public class CrosFilter implements Filter {
@Override
public void destroy() { }
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
HttpServletResponse hsr =(HttpServletResponse)response;
hsr.addHeader("Access-Control-Allow-Origin","http://localhost:8081"); //添加Origin
filterChain.doFilter(request,response);
}
@Override
public void init(FilterConfig arg0) throws ServletException { }
}
此时就可以通过ajax访问到资源了,这种情况只能允许一种域名访问,如果想让多个域名都访问到此接口,可以用*号代替上面设置的参数。如
hsr.addHeader("Access-Control-Allow-Origin", "*");
- 预检命令
其实浏览器将CORS分为两类, 简单请求和非简单请求,每次请求会先判断是否为简单请求。
- 如果是简单请求就先执行后判断资源信息是否允许跨域,这也是案例一中为什么请求的状态是200,但是无法获取数据的原因了。
- 如果不是简单请求会先发一个预检命令,检查通过以后才会把跨域请求发送过去。
- 像PUT,DELETE方法的ajax请求就属于非简单请求,而像GET、HEAD、POST方法的ajax请求,如果不考虑其他因素都属于简单请求,但是带json参数或者自定义头的ajax请求就属于非简单请求。
如下,实现一种带json参数的ajax请求
var params={username :"user", password:"123"};
function getFirstData(){
$.ajax({
type : "POST",
data: JSON.stringify(params),
url:"http://localhost:8080/getData/postUser",
contentType:"application/json;charset=UTF-8",
success:function(json){
console.log(json);
}
});
}
后台代码
@PostMapping("/postUser")
private DataSource postUser(@RequestBody User user){
System.out.println("postUser success");
return new DataSource("postUser success");
}
如下,访问了一次,出现了两条请求数据。
第一条OPTIONS方法的请求就是预检请求,通过实例测试会发现,在预检的时候,请求头里面会出现一个头信息
Access-Control-Request-Headers:content-type
意思是说它会询问一下后台服务器是否允许这个头,如果响应头里没有对应的信息就会报错,所以跨域请求就失败了。如下
因此我们需要在过滤器中加上对应的响应头,如下: hsr.addHeader("Access-Control-Allow-Headers", "Content-Type");
到这里有个问题了,如果每次请求都会预检未免多此一举,那么我们可以利用下面这个响应头设置预检结果缓存时间,单位为秒。 hsr.addHeader("Access-Control-Max-Age", "3600"); 这样设置以后,浏览器再次访问此域名时,一个小时内都不用预检。
携带cookie跨域请求
还有一种情况,在请求资源的时候往往需要带上cookie信息,cookie中记录了用户的信息以及session会话的id等。可以用下面这种方式,在ajax请求中携带cookie信息。
$.ajax({
type : "GET",
url:"http://localhost:8080/getData/getCookie",
xhrFields{
withCredentials:true
},
success:function(json){
console.log(json);
}
});
后台代码
@GetMapping("/getCookie")
private DataSource getCookie(@CookieValue(value="name")String cookie){
System.out.println("getCookie success");
return new DataSource("getCookie success");
}
然后在过滤器中还需要设置响应头,如:hsr.addHeader(“Access-Control-Allow-Credentials”,”true”);
接着在浏览器控制台下添加一个cookie信息,如:document.cookie=“name=tj”
测试发现,如果响应头里是设置了hsr.addHeader("Access-Control-Allow-Origin", ""),是不允许通过的,需要设置全匹配,不能用通配符。
如果我们需要支持多个域名可以访问,服务端可以先从request中将origin中的域名信息取出来,然后赋值给响应头的origin就可以了。
HttpServletRequest req =(HttpServletRequest) request;
String origin = req.getHeader("origin");
if(origin!=null){
hsr.addHeader("Access-Control-Allow-Origin",origin);
}
自定义头的跨域请求
还有一种自定义头的跨域也属于非简单跨域,解决方式和cookie的类似。
添加头的操作
type:"get",
url:"http://localhost:8080/getData/getFourthData",
headers:{
"myheader":"qunar"
},
success:function(json){
console.log(json);
}
});
后台先从request中取出头信息,然后判断是否为空,然后赋值给响应头。
String headers =req.getHeader("Access-Control-Request-Headers");
if(headers!=null){
hsr.addHeader("Access-Control-Allow-Headers",headers);
}
如此便成功完成了跨域请求。下面是拦截器中完整的配置。
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
HttpServletResponse hsr = (HttpServletResponse)response;
HttpServletRequest req = (HttpServletRequest) request;
String origin = req.getHeader("origin");
String headers = req.getHeader("Access-Control-Request-Headers");
if(origin!=null){
hsr.addHeader("Access-Control-Allow-Origin", origin);
}
if(headers!=null){
hsr.addHeader("Access-Control-Allow-Headers", headers);
}
hsr.addHeader("Access-Control-Max-Age", "3600");
hsr.addHeader("Access-Control-Allow-Credentials","true");
hsr.addHeader("Acccess-Control-Allow-Methods", "POST, GET, OPTIONS,DELETE,PUT");
filterChain.doFilter(request, response);
}
springMVC注解实现跨域请求
其实后端实现跨域请求并不用这么麻烦,springMVC的4.2版本以后提供了注解的方式解决跨域问题,在类上添加CrossOrigin注解,如下
@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/getData")
public class GetDataController {
...//此处省略
}
注释掉前面过滤器的配置,上面的所有请求也都能访问了。
小结: 有如此利器,还要介绍前面的解决方案,是为了更好的理解跨域解决思路。在不满足注解的框架中也能很好的实现跨域请求。
代理服务器实现跨域
到这里,基本上已经了解了java后端解决跨域的办法。而实际应用的部署环境往往会添加代理服务器,如下
那么我们可以考虑从代理服务器端解决跨域问题。解决的思路相同,直接说配置方式。
被调用端支持跨域配置
这种场景配置的是被调用端的代理服务器。在浏览器某个域(http://location.company.com)中请求不同域(http://app.company.com)的资源,首先将请求发送给被调用端的代理服务器,由代理服务器将请求路由到相应的资源服务器,而资源服务器并不用管请求方是谁,这种代理方式我们称之为正向代理。
Nginx中配置
在nginx.conf文件中配置如下。
注意:请求头的参数在这里都需要小写,并且“-”需要转成下划线, If后面需要带上空格,否则语法会报错。
Apache中配置
在httpd-vhosts.conf中配置虚拟主机相关配置。
注意:在httpd.conf中将vhost相关配置打开。并且将proxy模块、proxy http模块、Heard模块、rewrite模块打开。
调用端反向代理实现跨域
以上都是从被调用端来解决的,属于支持跨域。当无法修改被调用方的时候,可以配置调用端代理服务器来实现跨域。浏览器向同一域下的反向代理服务器发出请求,再由反向代理服务器转发,向其他域请求资源并返回给浏览器,浏览器不知道请求的资源在哪个服务器上,这种代理方式我们称之为反向代理。
Nginx中配置
Apache配置
总结
全文主要是通过ajax请求不同域下接口资源的场景,详细的分析了跨域的原理以及如何规避这种跨域。首先介绍了浏览器端解决跨域,script标签解决跨域和jsonp的方式解决跨域,但是这些方式都有明显的缺陷,接着重点介绍了cors如何解决跨域,相信通过本文,可以对跨域有一定的理解和解决思路了。