事件起因

一个需求让我开放一个 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 中对于跨域的常见解决方案,主要分为以下几种。

  1. 直接设置请求头
  2. @CrossOrigin 注解
  3. WebMvcConfigurer 配置
  4. 使用 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 时也不会出现跨域错误了

至此,问题解决完成。