事件起因
一个需求让我开放一个 HTTP 接口给前端,在联调的过程中,前端请求时出现了一个 CORS 错误,也即跨域问题,错误如下 👇
一开始我的想法是,跨域问题,这我熟啊,在学校写代码的时候就经常遇到,这解决起来不是分分钟的吗。
可更改之后我傻眼了,为什么一直不生效?我陷入了沉思。
在继续描述之前,我们先来了解下到底什么是跨域以及常见的解决方案有哪些。
什么是跨域
所谓跨域,全称是 跨源资源共享 (CORS) Cross- Origin Resource Sharing ,是一种基于 HTTP Header 的机制,该机制通过允许服务器标示除了它自己以外的其它 origin(域,协议和端口),这样浏览器可以访问加载这些资源。
举个例子:运行在 https://domain-a.com 的 JavaScript 代码使用 XMLHttpRequest
分别发起两个请求 👇
请求A: https://domain-a.com/query
请求B: https://domain-b.com/query
由于发请求的页面站点为 domain-a.com,所以请求 A 属于同源请求,domain-a.com 的后台服务器是允许请求。但是请求 B 的站点域是 domain-b.com,如果要发请求到 domain-b.com,就属于跨源访问,出于安全性考虑,浏览器限制脚本内发起的跨源 HTTP 请求。如下图所示:
因此,为了解决上述问题,跨源域资源共享( CORS )机制就应运而生了。该机制允许 Web 应用服务器进行跨源访问控制,从而使跨源数据传输得以安全进行。
CORS 工作机制
跨源资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站通过浏览器有权限访问哪些资源。
而且对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨源请求。
只有在服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。
大致流程如上图所示,CORS 请求失败会产生错误,但是为了安全,在 JavaScript 代码层面是无法获知到底具体是哪里出了问题。你只能查看浏览器的控制台以得知具体是哪里出现了错误。
在新增的这一组 HTTP 首部字段中,最重要的便是 Access-Control-Allow-Origin,其语法如下:
Access-Control-Allow-Origin: <origin> | *
其中,origin 参数的值指定了允许访问该资源的外域 URI。对于不需要携带身份凭证的请求,服务器可以指定该字段的值为通配符,表示允许来自所有域的请求。
例如,下面的字段值将允许来自 http://www.domain-a.com 的请求:
Access-Control-Allow-Origin: http://www.domain-a.com
如果服务端指定了具体的域名而非“*”,那么响应首部中的 Vary 字段的值必须包含 Origin。这将告诉客户端:服务器对不同的源站返回不同的内容。
接下来所说的解决方案主要就是围绕这一字段进行的。
Spring 中对于跨域的常见解决方案
本节介绍一下使用 Spring 中对于跨域的常见解决方案,主要分为以下几种。
- 直接设置请求头
- @CrossOrigin 注解
- WebMvcConfigurer 配置
- 使用 CorsFilter 拦截器
这一块的知识点网上一搜一大把,这里就不展开说明了。重点回到本次的问题分析。
上述方案使用结束之后仍然失效?
解决这个问题,经历了几个过程。
使用 @CrossOrigin 注解,接口 1、2 请求正常,但该方案不够通用,暂时舍弃。
使用 WebMvcConfigurer 配置的 addCorsMappings 方法配置接口 3 时失败,仍然出现跨域问题。
查找资料发现,这有可能是客户端请求经过的先后顺序问题,当服务端接收到一个请求时,该请求会先经过过滤器,然后进入拦截器中,然后再进入 Mapping 映射中的路径所指向的资源,所以跨域配置在 mapping 上并不起作用,返回的头信息中并没有配置的跨域信息,浏览器就会报跨域异常。
因此才会出现这种情况,当你在项目中使用了该方法配置跨域问题后,再使用自定义的拦截器时,跨域问题的相关配置就会失效,请求依然会报跨域问题的错。
此时我选择了最后一种方案,也即,直接使用 CorsFilter 拦截器。
在配置好拦截器之后,仍然出现跨域问题,此时的我心态崩了。
治标 or 治本
后来,我意外的发现前端在调用接口时的 URL 有问题,并没有按照我给他的规则去拼接 URL,果然,在请求了正确的 URL 之后,跨域问题,随即消失了。
也就是说,整个事件出现的原因是因为请求参数异常。
至此,这个问题其实已经解决了,治标已经完成。
只是,这时我又产生了新的疑问,为什么请求参数异常没有走到业务逻辑处理而是出现了跨域问题 🤔️
让我们情景再现一下 👇
@GetMapping("/error")
public ApiResult<String> errorTest(@RequestParam String id) {
if (StringUtils.isBlank(id)) {
return RestUtil.error(1, "参数异常");
}
return RestUtil.success(id);
}
代码样例如上,请求情况如下 👇
fetch("http://www.a.com/error"); // 出现跨域
fetch("http://www.a.com/error?id=123"); // 正常访问
经师兄提点,猜想是由于系统内部抛了异常被拦截后自动重定向到淘宝错误页,果然,在我直接使用浏览器访问上述 URL 后,果然跳转到了淘宝的错误页。
而系统之所以会报异常,原因出在 @RequestParam
注解上,让我们看一下他的源码
默认该参数是必传的!
如果不传该参数,甚至进不去业务逻辑的错误校验,从而直接被系统捕获了,接下来就是我们刚才熟知的那一幕了。
所以,其实只要设置成 @RequestParam(required = false)
,或者不使用该注解,即可从根源上解决该问题。
刨根问底一下
其实从问题的解决角度来说,到这里已经可以了,只不过刨根问底一下,为什么请求错误了会跳到淘宝的错误页,而不是显示 tomcat 的错误页呢?
在询问了师兄和查找相关资料后,我发现,是由于 tengine(阿里内部的魔改 Nginx)的 error_page 配置造成的,在 proxy_intercept_errors 配置成功后,使得在发生错误时自动重定向到淘宝错误页,所以即使在业务接口做了跨域处理,前端仍会出现跨域问题,因为这一次跨到了淘宝错误页的域。
nginx 配置目录在 /home/admin/cai/conf
配置文件中并未出现重定向页面,重定向页面的配置在另一个文件中 /opt/taobao/tengine/conf/services.conf
error_page 400 http://err.taobao.com/error1.html;
error_page 403 http://err.taobao.com/error1.html;
error_page 404 http://err.taobao.com/error1.html;
error_page 405 http://err.taobao.com/error1.html;
error_page 408 http://err.taobao.com/error1.html;
error_page 410 http://err.taobao.com/error1.html;
error_page 411 http://err.taobao.com/error1.html;
error_page 412 http://err.taobao.com/error1.html;
error_page 413 http://err.taobao.com/error1.html;
error_page 414 http://err.taobao.com/error1.html;
error_page 415 http://err.taobao.com/error1.html;
error_page 500 http://err.taobao.com/error2.html;
error_page 501 http://err.taobao.com/error2.html;
error_page 502 http://err.taobao.com/error2.html;
error_page 503 http://err.taobao.com/error2.html;
error_page 506 http://err.taobao.com/error2.html;
重新加载配置 /home/admin/cai/bin/nginxctl reload
至此,刨根问底结束。
总结一下解决方案
方案 1:关闭 nginx 的 proxy_intercept_errors 选项
proxy_intercept_errors
指令后面跟on
或off
,分别表示 打开 或 关闭 拦截错误页功能。
方案 2:避免在请求时直接产生错误,在本例中是请求参数缺失的问题
@RequestParam
注解默认是必传的,如果没有会报 400 错误,所以才会重定向到淘宝错误页。
更改为 @RequestParam(required = false)
然后在接下来的业务逻辑中进行校验
@GetMapping("/error")
public ApiResult<String> errorTest(@RequestParam(required = false) String id) {
if (StringUtils.isBlank(id)) {
return RestUtil.error(1, "参数异常");
}
return RestUtil.success(id);
}
实验验证
有了合理的猜想后,我们需要来验证一下,到底是不是上述猜想导致的,因此需要验证一下。
验证:修改 nginx 的 proxy_intercept_errors
配置选项,将拦截关闭
预期效果:不会重定向,且出现原生的 tomcat 错误页面
实验后:
控制台 fetch 时也不会出现跨域错误了
至此,问题解决完成。