预检请求机制:OPTIONS方法与CORS头
跨域请求这个坑,我早期做API网关时踩过不少次。你想想看,前端发个请求过去,后端明明配了跨域头,结果浏览器还是给你拦下来。为啥?因为浏览器没搞清楚你是不是“安全”的请求。这个安全核查机制,就是预检请求(Preflight Request)。
说白了,浏览器在正式请求之前,先发一个OPTIONS请求去探路。服务器得给出明确答复——允许哪些源、哪些方法、哪些头。预检通过后,浏览器才放心地把真正的请求发出去。
我记得有次线上排查跨域问题,查了半天发现是后端的跨域头没写对,预检直接挂了,业务请求全被拦截。嗯,从那以后我就把预检机制研究透了。
什么时候会触发预检?
并非所有跨域请求都会触发预检。浏览器把请求分成两类:简单请求和复杂请求。简单请求直接发,不预检;复杂请求必须先过预检这一关。
| 分类 | 触发条件 | 是否需要预检 |
|---|---|---|
| 简单请求 |
|
否 |
| 复杂请求 |
|
是 |
OPTIONS请求长什么样
浏览器自动发起的预检请求,大致长这样:
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://frontend.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
注意两个关键头:
- Origin:告诉服务器,是哪个源在请求。
- Access-Control-Request-Method:接下来真正的请求要用什么方法。
- Access-Control-Request-Headers:接下来真正的请求会带哪些自定义头。
服务器收到这个OPTIONS请求后,需要在响应里明确表态:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frontend.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
这里每个头都很关键:
- Access-Control-Allow-Origin:允许的源。可以是单个源,也可以是 *(但带凭证的请求不能是*)。
- Access-Control-Allow-Methods:允许的方法列表。
- Access-Control-Allow-Headers:允许的请求头。
- Access-Control-Max-Age:预检结果可以缓存多久(单位秒)。缓存期内同一个源再次发同类型请求,浏览器不会再发OPTIONS。
预检请求的完整流程
用一张图把整个流程说清楚:
你看流程图里,API网关的角色很关键。它接住OPTIONS请求后,可以自己判断跨域规则,不一定非得扔给后端。这样做的好处是:
- 性能提升:网关直接返回204,不占用后端连接池。
- 统一管理:跨域规则在网关层配置,后端服务不用重复写。
- 安全管控:可以在网关层黑名单、白名单,防止绕过。
CORS响应头详解
除了上面提到的几个头,还有几个关键响应头需要掌握:
| 响应头 | 作用 | 示例值 |
|---|---|---|
| Access-Control-Allow-Origin | 允许的源 | https://example.com 或 * |
| Access-Control-Allow-Methods | 允许的HTTP方法 | GET, POST, PUT, DELETE |
| Access-Control-Allow-Headers | 允许的请求头 | Content-Type, Authorization |
| Access-Control-Allow-Credentials | 是否允许携带凭证(cookie、Authorization头等) | true |
| Access-Control-Expose-Headers | 浏览器可以读取哪些响应头 | X-Total-Count, X-Request-Id |
| Access-Control-Max-Age | 预检结果缓存时间(秒) | 86400 |
实际编码:Netty网关处理OPTIONS
如果你自己写网关,处理OPTIONS请求的代码可以这样写(以Netty为例):
// 在ChannelHandler中处理OPTIONS请求
if (request.method().equals(HttpMethod.OPTIONS)) {
HttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT);
response.headers().set("Access-Control-Allow-Origin", "https://frontend.com");
response.headers().set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
response.headers().set("Access-Control-Allow-Headers", "Content-Type, Authorization");
response.headers().set("Access-Control-Max-Age", "86400");
// 直接返回,不转发到后端
ctx.writeAndFlush(response);
return;
}
在实际生产系统中,我会把允许的源列表做成可配置的,甚至做成动态加载,方便运维调整。我个人习惯是把跨域规则单独做一个Handler,放在网关管道的最前面,这样OPTIONS请求一到就命中,完全不干扰后面的业务逻辑。
最佳实践:网关层统一处理
根据我多年的网关开发经验,总结几条建议:
- 集中配置:跨域规则放在网关配置中心,每个服务不用管。
- 缓存预检结果:Access-Control-Max-Age建议设成3600~86400秒,减少预检次数。
- 不要暴露内部源:Access-Control-Allow-Origin尽量写具体的源,不要用 *,除非是公共API。
- 记录预检日志:给网关加一条trace日志,记录OPTIONS请求的来源和方法,方便排查。
curl -X OPTIONS -H "Origin: https://frontend.com" -H "Access-Control-Request-Method: POST" https://api.example.com/resource -v
预检请求机制,说白了就是浏览器和API网关之间的一次握手。你只要记住:复杂请求必须先过OPTIONS这一关;网关要在这一关就把不安全的请求拒绝掉,而不是让真正的请求打到后端。这样一来,安全、性能和可维护性都有了保障。