后端开发问题详解
前后端联调中的跨域问题
2024-03-31 20:42:51
为什么会出现跨域问题
首先我们要明白什么是跨域,对于浏览器而言跨域中的域指的是**域名+端口, 意思是如果当前站点 site.pandaer.com
中的JS文件发送了一个不同域的请求,这种情况就是跨域。记住这是浏览器的安全策略,主要是为了防止一些Cookie信息泄露,举个例子,你登陆了网易云音乐,浏览器在Cookie中存有登录的Token,这个时候你又打开了另外一个站点,然后这个站点的JS文件向网易云发送了一个请求,请求的内容是获取你喜欢的音乐名单,这个时候由于没有跨域的限制,发送请求时,浏览器会自动带上你的Cookie,你喜欢音乐的名单就在你不知情的情况下被泄露了。有了跨域之后,浏览器就会阻止这一行为,注意!!!是浏览器阻止。那为什么移动端和桌面端没有这个问题呢?因为桌面端和移动端的程序数据不共享,所以就拿不到对应的Cookie信息,所以就没有这个跨域限制呗。
如何解决跨域问题
有时候,我们就是想获取其他站点的资源,这个时候怎么办呢?HTTP的规范提出了一种解决办法 -- CORS(Cross-Origin Resource Sharing),另外还有很多解决办法,比如可以想法设法不跨域,比如反向代理。而且CORS只是一种规范,有许多具体的实现,这里主要聊聊SpringBoot的落地实现。
如果你遇到过跨域问题,对下面的代码一定很熟悉,但是它并不完全,或者说在某些时候会出问题,主要发生在我们有自己的拦截器,或者过滤器的时候**。
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowCredentials(true)
.allowedHeaders("*")
.allowedOriginPatterns("*")
.allowedMethods("*")
.maxAge(3600);
}
}
首先我们先简单来复习一下CORS的规范,对于请求分为两种,一种是简单请求,一种是为复杂请求(为了方便对应),浏览器在正式发起复杂请求时,会先发送一个预检请求(options),判断这个额外的站点是否支持资源共享,如果没有回应这个请求,或者这个请求的状态码不是2xx,那么浏览器就认为额外的站点不支持跨域,即不支持资源的共享。接下来我们通过debug的方式来了解一下SpringBoot是如何实现CORS的规范的。
实现CORS,其实主要就是处理好options这个预检请求,以及对跨域请求的响应头的设置。那我们的第一个断点应该打在哪里呢?我们知道无论怎么样,预检请求首先的是一个HTTP的请求,熟悉SpringMVC的伙伴应该知道SpringMVC有一个核心的类DisapatchServlet
,里面有个很重要的方法doDispatch
所有的请求都会先到这里,然后由这个方法去寻找可以处理这个请求的处理链,并交给这个处理链去处理。那么我们的第一个断点就下这个方法,等待我们的预检请求。如下图
这个时候我们抓住了预检请求,然后一步一步让他执行下去,看看是如何处理的
当执行到这里的时候,上面有一段注释很有意思:获取处理当前请求的handler,
我们看到了一个很有趣的单词,preflight
他的中文意思是预检
看看官方描述,所以我们有理由相信,这个AbstractHandlerMapping
中的PreFlightHandler
一个和预检请求有关,于是我们的第二个断点就找到了,
这次更加确定了,因为我们看见了CorsInterceptor
,我们可以大胆猜测一下,对于CORS的落地实现应该用的是Interceptor
的机制,于是我们的第三个断点找到了
看到这段代码,我们可以确定主要逻辑在最后一段代码中corsProcessor.processRequest(this.config, request, response);
,然后我们去看看
这是一个抽象方法,额,怎么办呢?退一步看看corsProcessor的具体实现,两种办法,直接利用idea中的eval能力,debug到这一步,然后eval就可以了,另外一种就是看看他在哪里被赋值或者初始化了。
很好,找到了具体的一个实现了,我们的第四个断点就找到了,去看看
public boolean processRequest(@Nullable CorsConfiguration config, HttpServletRequest request,
HttpServletResponse response) throws IOException {
Collection<String> varyHeaders = response.getHeaders(HttpHeaders.VARY);
if (!varyHeaders.contains(HttpHeaders.ORIGIN)) {
response.addHeader(HttpHeaders.VARY, HttpHeaders.ORIGIN);
}
if (!varyHeaders.contains(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD)) {
response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD);
}
if (!varyHeaders.contains(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS)) {
response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
}
if (!CorsUtils.isCorsRequest(request)) {
return true;
}
if (response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN) != null) {
logger.trace("Skip: response already contains \"Access-Control-Allow-Origin\"");
return true;
}
boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);
if (config == null) {
if (preFlightRequest) {
rejectRequest(new ServletServerHttpResponse(response));
return false;
}
else {
return true;
}
}
return handleInternal(new ServletServerHttpRequest(request), new ServletServerHttpResponse(response), config, preFlightRequest);
}
截图不好看,直接把代码拿过来,前面一堆的判断都在往一个响应头Vary
,加入一些东西,这个对于我们来说不是很重要,可以暂时跳过,我们先建立一个todo。
- Vary的作用
boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);
看看这段代码,这里就是在判断这个请求是不是预检请求。
handleInternal(new ServletServerHttpRequest(request), new ServletServerHttpResponse(response), config, preFlightRequest);
这段代码也是一个核心
走,我们去看看
protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse response,
CorsConfiguration config, boolean preFlightRequest) throws IOException {
//取出请求头中的origin
String requestOrigin = request.getHeaders().getOrigin();
//请求中origin是否在配置中
String allowOrigin = checkOrigin(config, requestOrigin);
//获取响应头
HttpHeaders responseHeaders = response.getHeaders();
//请求中的origin不在配置中,直接拒绝请求
if (allowOrigin == null) {
logger.debug("Reject: '" + requestOrigin + "' origin is not allowed");
rejectRequest(response);
return false;
}
//获取到真实请求方法
HttpMethod requestMethod = getMethodToUse(request, preFlightRequest);
List<HttpMethod> allowMethods = checkMethods(config, requestMethod);
//判断真实请求的方法是否在配置中 不在就拒绝
if (allowMethods == null) {
logger.debug("Reject: HTTP '" + requestMethod + "' is not allowed");
rejectRequest(response);
return false;
}
//同理,获取真实请求的请求头
List<String> requestHeaders = getHeadersToUse(request, preFlightRequest);
List<String> allowHeaders = checkHeaders(config, requestHeaders);
//如果请求头不在,拒绝
if (preFlightRequest && allowHeaders == null) {
logger.debug("Reject: headers '" + requestHeaders + "' are not allowed");
rejectRequest(response);
return false;
}
//将请求中的origin设置到AccessControlAllowOrigin请求头中
responseHeaders.setAccessControlAllowOrigin(allowOrigin);
//预检请求就设置AccessControlAllowMethod的请求头
if (preFlightRequest) {
responseHeaders.setAccessControlAllowMethods(allowMethods);
}
//同理
if (preFlightRequest && !allowHeaders.isEmpty()) {
responseHeaders.setAccessControlAllowHeaders(allowHeaders);
}
//同理
if (!CollectionUtils.isEmpty(config.getExposedHeaders())) {
responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());
}
//同理
if (Boolean.TRUE.equals(config.getAllowCredentials())) {
responseHeaders.setAccessControlAllowCredentials(true);
}
//同理
if (Boolean.TRUE.equals(config.getAllowPrivateNetwork()) &&
Boolean.parseBoolean(request.getHeaders().getFirst(ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK))) {
responseHeaders.set(ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK, Boolean.toString(true));
}
//同理
if (preFlightRequest && config.getMaxAge() != null) {
responseHeaders.setAccessControlMaxAge(config.getMaxAge());
}
response.flush();
//放行
return true;
}
看完这段代码,你就知道了SpringBoot是如何落地实现CORS的了。
逻辑很简单就是根据CORS的规范设置几个响应头就好了,而且那几个响应头还都是在预检请求中呢。
读完这段代码不知道你发现一个问题没有,SpringBoot的实现中,并没有将Options请求立即打回,仅仅是flush了一下,为什么要这样设计了?为什么不立即打回?这两个问题值得思考,但是我还没有想法,我认为立即打回更好,这样就不会导致后面自定义了拦截器和过滤器产生的跨域请求失败的问题了。那自定义了拦截器和过滤器为什么就会导致跨域请求失败呢?原因就在于浏览器不仅仅要看option的响应头是否带了规范中提到了响应头,整体这个Options响应必须是OK的,即2xx的响应码。
看吧,响应头中该有的东西都有,只是没有返回正确的响应码还是会导致跨域请求失败。然后我们在实现拦截器和过滤器的时候,我们一般只考虑业务吧,没有单独考虑到这个预检请求的问题吧。
所以我的建议是如何你有自定义拦截器和过滤器,那么我建议专门定义一个解决预检请求的拦截器,来处理预检请求就像下面这样
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
response.setStatus(HttpStatus.NO_CONTENT.value());
return false;
}
所以这就是我有点不理解的地方,SpringBoot为什么不直接在设置完响应头后就打回预检请求呢?
到这里,对于SpringBoot是如何实现CORS规范的就解释完了,本次实验用的SpringBoot的版本是v3.2.4
SpringBoot其实也提供了注解来解决跨域问题,这个注解可以更加细粒度的控制需要共享的资源,今天就不具体展开讲了,如果感兴趣可以看看官方文档:https://spring.io/guides/gs/rest-service-cor
总结
跨域问题是HTTP为了解决用户的隐私问题而产生的,如果我们就是想要跨站点共享资源,可以试试官方的CORS规范,现在主流的框架都有对其的具体实现,不需要我们手动实现。
然后解释了SpringBoot处理跨域请求时会遇到的一些问题即自定义拦截器和过滤器的时候。