什么是Cors跨域
Cors(Cross-origin resource sharing):跨域资源共享,它是浏览器的一个技术规范,由W3C规定,规范的wiki地址在此:https://www.w3.org/wiki/CORS_Enabled#What_is_CORS_about.3F
话外音:它是浏览器的一种(自我保护)行为,并且已形成规范。也就是说:backend请求backend是不存在此现象的喽
若想实现Cors机制的跨域请求,是需要浏览器和服务器同时支持的。关于浏览器对CORS的支持情况:现在都2021年了,so可以认为100%的浏览器都是支持的,再加上CORS的整个过程都由浏览器自动完成,前端无需做任何设置,所以前端工程师的ajax原来怎么用现在还是怎么用,它对前段开发人员是完全透明的。
为何需要Cors跨域访问?
浏览器费尽心思的搞个同源策略来保护我们的安全,但为何又需要跨域来打破这种安全策略呢?其实啊,这一切都和互联网的快速发展有关~
随着Web开放的程度越来越高,页面的内容也是越来越丰富。因此页面上出现的元素也就越来越多:图片、视频、各种文字内容等。为了分而治之,一个页面的内容可能来自不同地方,也就是不同的domain域,因此通过API跨域访问成了必然。
浏览器作为进入Internet最大的入口,很长时间它是个大互联公司的必争之地,因此市面上并存的浏览器种类繁多且鱼龙混扎:IE 7、8、9、10,Chrome、Safari、火狐,每个浏览器对跨域的实现可能都不一样。因此对开发者而言亟待需要一个规范的、统一方案,它就是Cors。
CORS(Cross-Origin Resource Sharing)由W3C组织于2009-03-17编写工作草案,直到2014-01-16才正式毕业成为行业规范,所有浏览器得以遵守。至此,程序员同学们在解决跨域问题上,只需按照Cors规范实施即可。
Cors的工作原理
Web资源涉及到两个角色:浏览器(消费者)和服务器(提供者),面向这两个角色来了解Cors的原理非常简单,如下图所示:
1.若浏览器发送的是个跨域请求,http请求中就会携带一个名为Origin的头表明自己的“位置”,如Origin: http://localhost:5432
2.服务端接到请求后,就可以根据传过来的Origin头做逻辑,决定是否要将资源共享给这个源喽。而这个决定通过响应头Access-Control-Allow-Origin来承载,它的value值可以是任意值,有如下情况:
无此头:不共享给此origin
有此头:值有如下可能情况
- 值为*,通配符,允许所有的Origin共享此资源
- 值为http://localhost:5432(也就是和Origin相同),共享给此Origin
- 值为非http://localhost:5432(也就是和Origin不相同),不共享给此Origin
3.浏览器接收到Response响应后,会去提取Access-Control-Allow-Origin这个头。然后根据上述规则来决定要接收此响应内容还是拒绝
Tips:Access-Control-Allow-Origin响应头只能有1个,且value值就是个字符串。另外,value值即使写为http://aa.com,http://bb.com这种也属于一个而非两个值
Cors细粒度控制:授权响应头
在Cors规范中,除了可以通过Access-Control-Allow-Origin响应头来对主体资源(URL级别)进行授权外,还提供了针对于具体响应头更细粒度的控制,这个响应头就是:Access-Control-Expose-Headers。换句话讲,该头用于规定哪些响应头(们)可以暴露给前端,默认情况下这6个响应头无需特别的显示指定就支持:
- Cache-Control
- Content-Language
- Content-Type
- Expires
- Last-Modified
- Pragma
若不在此值里面的头将不会返回给前端(其实返回了,只是浏览器让其对前端不可见了而已,对JavaScript也不可见哦)。
但是,但是,但是,这种细粒度控制header的机制对简单请求是无效的,只针对于非简单请求(也叫复杂请求)。由此可见,将哪些类型的跨域资源请求划分为简单请求的范畴就显得特备重要了。
何为简单请求
Cors规范定义简单请求的原则是:请求不是以更新(添加、修改和删除)资源为目的,服务端对请求的处理不会导致自身维护资源的改变。对于简单跨域资源请求来说,浏览器将两个步骤(取得授权和获取资源)合二为一,由于不涉及到资源的改变,所以不会带来任何副作用。
对于一个请求,必须同时符合如下要求才被划为简单请求:
1.Http Method只能为其一:
- GET
- POST
- HEAD
2.请求头只能在如下范围:
1.Accept
2.Accept-Language
3.Content-Language
4.Content-Type,其中它的值必须如下其一:
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
除此之外的请求都为非简单请求(也可称为复杂请求)。非简单请求可能对服务端资源改变,因此Cors规定浏览器在发出此类请求之前必须有一个“预检(Preflight)”机制,这也就是我们熟悉的OPTIONS请求。
什么是Preflight预检机制
顾名思义,它表示在浏览器发出真正请求之前,先发送一个预检请求,这个在Http里就是OPTIONS请求方式。这个请求很特殊,它不包含主体(无请求参数、请求体等),主要就是将一些凭证、授权相关的辅助信息放在请求头里交给服务器去做决策。因此它除了携带Origin请求头外,还会额外携带如下两个请求头:
- Access-Control-Request-Method:真正请求的方法
- Access-Control-Request-Headers:真正请求的自定义请求头(若没有自定义的就是空呗)
服务端在接收到此类请求后,就可以根据其值做逻辑决策啦。如果允许预检请求通过,返回个200即可,否则返回400或者403呗。
如果预检成功,在响应里应该包含上文提到的响应头Access-Control-Allow-Origin和Access-Control-Expose-Headers,除此之外,服务端还可以做更精细化的控制,这些精细化控制的响应头为:
- Access-Control-Allow-Methods:允许实际请求的Http方法(们)
- Access-Control-Allow-Headers:允许实际请求的请求头(们)
- Access-Control-Max-Age:允许浏览器缓存此结果多久,单位:秒。有了缓存,以后就不用每次请求都发送预检请求啦
说明:以上响应头并不是必须的。若没有此响应头,代表接受所有
预检请求完成后,有个关键点,便是浏览器拿到预检请求的响应后的处理逻辑,这里描述如下:
- 先通过自己的Origin匹配预检响应中的Access-Control-Allow-Origin的值,若不匹配就结束请求,若匹配就继续下一步验证
关于Access-Control-Allow-Origin的验证逻辑,请参考文上描述
- 拿到预检响应中的Access-Control-Allow-Methods头。若此头不存在,则进行下一步,若存在则校验预检请求头Access-Control-Request-Method的值是否在此列表中,在其内继续下一步,否则失败
- 拿到预检响应中的Access-Control-Request-Headers头。同请求头中的Access-Control-Allow-Headers值记性比较,全部包含在内则匹配成功,否则失败
以上全部匹配成功,就代表预检成功,可以开始发送正式请求了。值得一提的事,Access-Control-Max-Age控制预检结果的浏览器缓存,若缓存还生效的话,是不用单独再发送OPTIONS请求的,匹配成功直接发送目标真实即可。
Access-Control-Max-Age使用细节
Access-Control-Max-Age用于控制浏览器缓存预检请求结果的时间,这里存在一些使用细节你需要注意:
1.若浏览器禁用了缓存,也就是勾选了Disable cache,那么此属性无效。也就说每次都还得发送OPTIONS请求
2.判断此缓存结果的因素有两个:
- 必须是同一URL(也就是Origin相同才会去找对应的缓存)
- header变化了,也会重新去发OPTIONS请求(当然若去掉一些header编程简单请求了,就另当别论喽)
跨域请求代码示例
正所谓说再多,也抵不上跑几个case,毕竟show me your code才是最重要。 下面就针对跨域情况的简单请求、非简单请求(预检通过、预检不通过)等case分别用代码(基于文首代码)说明。
简单请求
简单请求正如其名,是最简单的请求方式。
// 跨域请求 $.get("http://localhost:8080/cors", function (result) { $("#content").append(result).append("<br/>"); });
服务端结果:
INFO ...CorsServlet - 收到请求:/cors,方法:GET, Origin头:http://localhost:63342
浏览器结果:
若想让请求正常,只需在服务端响应头里“加点料”就成:
... resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342"); resp.getWriter().write("hello cors..."); ...
再次请求,结果成功:
对于简单请求来讲,服务端只需要设置Access-Control-Allow-Origin这个一个头即可,一个即可。
非简单请求
非简单请求的模拟非常简单,随便打破一个简单请求的约束即可。比如我们先在上面get请求的基础上自定义个请求头:
$.ajax({ type: "get", url: "http://localhost:8080/cors", headers: {secret:"kkjtjnbgjlfrfgv",token: "abc123"} });
服务端代码:
/** * 在此处添加备注信息 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/6/9 10:36 * @since 0.0.1 */ @Slf4j @WebServlet(urlPatterns = "/cors") public class CorsServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String requestURI = req.getRequestURI(); String method = req.getMethod(); String originHeader = req.getHeader("Origin"); log.info("收到请求:{},方法:{}, Origin头:{}", requestURI, method, originHeader); resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342"); resp.setHeader("Access-Control-Expose-Headers","token,secret"); resp.setHeader("Access-Control-Allow-Headers","token,secret"); // 一般来讲,让此头的值是上面那个的【子集】(或相同) resp.getWriter().write("hello cors..."); } }
点击按钮,浏览器发送请求,结果为:
服务端没有任何日志输出,也就是说浏览器并未把实际请求发出去。什么原因?查看OPTIONS请求的返回一看便知:
根本原因为:OPTIONS的响应头里并未含有任何跨域相关信息,虽然预检通过(注意:这个预检是通过的哟,预检不通过的场景就不用额外演示了吧~),但预检的结果经浏览器判断此跨域实际请求不能发出,所以给拦下来了。
从代码层面问题就出现在resp.setHeader(xxx,xxx)放在了处理实际方法的Get方法上,显然不对嘛,应该放在doOptions()方法里才行:
@Override protected void doOptions(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { super.doOptions(req, resp); resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342"); resp.setHeader("Access-Control-Expose-Headers","token,secret"); resp.setHeader("Access-Control-Allow-Headers","token,secret"); // 一般来讲,让此头的值是上面那个的【子集】(或相同) }
在此运行,一切正常:
值得特别注意的是:设置跨域的响应头这块代码,在处理真实请求的doGet里也必须得有,否则服务端处理了,浏览器“不认”也是会出跨域错误的。
另外就是,Access-Control-Allow-Headers/Access-Control-Expose-Headers这两个头里必须包含你的请求的自定义的Header(标准的header不需要包含),否则依旧跨域失败哦~
在实际生产场景中,Http请求的Content-type大都是application/json并非简单请求的头,所以有个现实情况是:实际的跨域请求中,几乎100%的情况下我们发的都是非简单请求。
Cros跨域使用展望
如上代码示例,处理简单请求尚且简单,但对于非简单请求来说,我们在doOptions和doGet都写了一段setHeader的代码,是否觉得麻烦呢?
另外,对于Access-Control-Allow-Origin若我需要允许多个源怎么办呢?
Tips:Access-Control-Allow-Origin头只允许一个,且Access-Control-Allow-Origin:a.com,b.com依旧算作一个源的,它没有逗号分隔的“特性”。从命名的艺术你也可看出,它并非是xxx-Origins而是xxx-Origin
既然实际场景中几乎100%都是非简单请求,那么对于控制非简单请求的Access-Control-Allow-Methods、Access-Control-Allow-Headers、Access-Control-Max-Age这些都都改如何赋值?是否有最佳实践?
现在我们大都在Spring Framework/Spring Boot场景下开发应用,框架层面是否提供一些优雅的解决方案?
作为一名后端开发工程师(编程语言不限),也许你从未处理过跨域问题,那么到底是谁默默的帮你解决了这一切呢?是否想知其所以然?
如果这些问题也是你在使用过程中的疑问,或者希望了解的知识点,那么请关注专栏吧。
总结
本文用很长的篇幅介绍了Cors跨域资源共享的相关知识,并且用代码做了示范,希望能助你通关Cors这个狗皮膏药一样粘着我们的硬核知识点。本文文字叙述较多,介绍了同源、跨域、Cors的几乎所有概念,虽然略显难啃,但这些是指导我们实践的说明书。
革命尚未统一,带着👆🏻给到的问题,一起开启通过Cors跨域之旅吧~
本文思考题
本文已被https://yourbatman.cn收录。所属专栏:点拨-Cors跨域,后台回复“专栏列表”即可查看详情。
看完了不一定懂,看懂了不一定会。来,3个思考题帮你复盘:
- 试想一下,如果浏览器没有同源策略,将有多大的风险?
- Cors共涉及到哪些请求头?哪些响应头?
- 你所知道的解决Cors跨域问题最佳实践是什么?