以前对“跨域”的含义只知道个大概,即浏览器出于安全考虑,不允许域名是 a 的网页,访问域名 b 的资源。
今天在 v2ex 上看了一篇关于跨域的帖子,对跨域和预检(preflight)有了更进一步的了解,打算自己做一个实验,来验证并加深自己的理解。
跨域
首先要澄清的是:跨域只会出现在浏览器环境中!,纯服务端之间不存在这个问题。
所谓的跨域,就是:网页地址的域名是 A ,但是接口请求的地址是 B 。这就是跨域,跨越了不同的域(名)想要去请求资源。它的严格定义和介绍可以看这里。
事实上,协议、域名、端口三者有任意一个不同,就算“跨域”了,例如:
https://mofish.zone:3000和https://mofish.zone:9527是跨域http://x和https://x是跨域
发生跨域问题的时候,我们一般会在控制台看到下面的错误:
Access to fetch at 'http://backend.com/test/cors/ping' from origin 'http://frontend.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
解决跨域问题
那么如何解决跨域问题呢?
粗略来讲,核心就是后端借助请求头 Access-Control-Allow-Origin 告诉浏览器,允许下列的 xxx 域名,访问我后端的资源
关于跨域还有其他一系列相关的 header,这里就不细看了。
这里的后端是相对前端来说的,它可以是真实的 web 服务,也可以是 nginx,也可以是其他任何可以修改 response header 的七层代理或中间件。
我们在 java springboot 服务中,可以通过给 controller 中的方法签名上增加 @CrossOrigin 注解来实现,比如下面的例子
@CrossOrigin(origins = "http://frontend.com", allowCredentials = "true")
@GetMapping("/test/cors/ping")
@ResponseBody
public String handshake() {
return "PONG";
}
浏览器在收到 response 之后,会发现 response 中有这么一个 header:Access-Control-Allow-Origin = http://frontend.com,这时候浏览器就会放行这个请求。
如果想偷懒的话,也可以做一个全局配置:
@Configuration
public class WebCorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://frontend.com") // 也可以直接写 * 表示任意 origin
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}
亦或者,我们也可以在 nginx 上配置,当处理发往后端 backend.com 的请求时,自动添加上面的这个 header:
server {
listen 80;
server_name backend.com; # 你的前端域名,或用于代理的域名
location / {
proxy_pass http://real.backend.com/; # 代理到真实的后端地址
# 添加 CORS 头:
# 星号表示支持任何源网址,也可以换成具体的域名。
# 但这里不支持写多个域名,如果要添加多个域名,则需要换一种写法。
# always 指在非 200 的请求也会返回跨域信息(如 4xx、5xx)
add_header 'Access-Control-Allow-Origin' '*' always;
# 允许的 http method
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
# 用于告诉浏览器,在跨域请求中除了默认允许的简单请求头外,还允许哪些额外的请求头。
add_header 'Access-Control-Allow-Headers' 'Authorization, X-Requested-With' always;
# 处理预检请求 (OPTIONS),下文会介绍
if ($request_method = OPTIONS ) {
# 预检请求缓存时间(单位:秒)
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
疑问:为什么本地开发环境没事?
前端开发的时候本地页面地址是 localhost ,接口地址肯定是其他的,为什么不跨域呢?
因为现在前端项目,开发用的脚手架通常会在本地起一个代理,统一将请求转发到后端,所以不会出现跨域问题。
动手实验
1. 修改本地 hosts
因为要在本地环境模拟,所以在 hosts 文件中将域名指向 127.0.0.1
127.0.0.1 frontend.com
127.0.0.1 backend.com
2. 创建前端页面
每次点击按钮,向后端发送一个请求,然后将 result 打印出来
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>跨域测试前端</title>
</head>
<body>
<h1>Frontend on frontend.com</h1>
<button onclick="callApi()">调用后端 API</button>
<pre id="result"></pre>
<script>
function callApi() {
fetch('http://backend.com/test/cors/ping', {})
.then(response => response.text())
.then(data => {
document.getElementById('result').textContent = data;
})
.catch(err => {
document.getElementById('result').textContent = 'Error: ' + err;
});
}
</script>
</body>
</html>
3. 创建 web 服务
@RestController
public class TestCorsController {
@GetMapping("/test/cors/ping")
@ResponseBody
public String handshake() {
return "PONG";
}
}
4. 修改 nginx 配置
当浏览器访问 http://frontend.com/index.html 的时候,返回上面创建的 html 页面。
当页面请求 http://backend.com/test/* 的时候会转发到我部署在 http://localhost:9010 上的 spring web 服务
# 前端页面
server {
listen 80;
server_name frontend.com;
location /index {
root C:/idea_project/demo/src/main/resources/;
index index.html;
}
}
# 后端服务
server {
listen 80;
server_name backend.com;
location /test/ {
proxy_pass http://localhost:9010;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
5. 发起请求,触发跨域问题
点击按钮,发起请求,可以看到前端提示 CORS 错误:
在 console 打印了详细的信息:
6. 配置 @CrossOrigin,解决跨域
我们修改 java 代码,加上 @CrossOrigin 注解:
@Slf4j
@RestController
public class TestCorsController {
@CrossOrigin(origins = "http://frontend.com", allowCredentials = "true")
@GetMapping("/test/cors/ping")
@ResponseBody
public String handshake() {
log.info("Ping received, return PONG");
return "PONG";
}
}
前端再次访问,就可以访问成功了。我们可以看到在 response header 中,明确的指出了 http://frontend.com 这个地址:
预检请求
当我们访问网页时,经常会看到请求类型为 OPTIONS 的预检请求,这也是浏览器用来确保跨域安全的策略之一。
我们上面 html 代码中,构造了一个最简单的 ping-pong 请求,遇到这种简单请求,浏览器会直接发送,不会预检。
简单请求的准确定义比较复杂,简单来说,就是满足下面所有条件的请求:
- 请求方法 GET、HEAD 或 POST
- 只能使用这些“安全的请求头”:Accept、Accept-Language、Content-Language、DPR、Downlink、Save-Data、Viewport-Width、Width
- 对于POST请求,如果Content-Type头部的值是 application/x-www-form-urlencoded、multipart/form-data或text/plain,则不会触发预检请求
- 请求中不能使用自定义 header(包括凭证信息,如Cookies、HTTP认证信息等)
从最后一条我们可以知道,只要给请求带上任意自定义 header,就可以构造一个“非简单请求”
当遇到了非简单请求时,浏览器就会先发送一个 OPTIONS 预检请求,问问后端配置的跨域策略是什么,如果能满足,则继续请求,如果不满足,就会报跨域错误。
下面我修改 html 网页源码,给请求携带一个自定义的 header:
function callApi() {
fetch('http://backend.com/test/cors/ping', {
headers: { "X-Custom-Header": "my_header" } // 自定义 header,会触发预检
})
.then(response => response.text())
.then(data => {
document.getElementById('result').textContent = data;
})
.catch(err => {
document.getElementById('result').textContent = 'Error: ' + err;
});
}
这时候再去访问后端,就会触发预检请求:
对于 spring web 项目,框架通常会帮我们处理预检请求,我们可以利用下面的 filter,来记录收到的预检请求:
@Slf4j
@Component
public class OptionRequestFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
log.info("Received OPTIONS request to: " + request.getRequestURI());
}
filterChain.doFilter(request, response);
}
}




