预检请求机制:OPTIONS方法与CORS头

跨域请求这个坑,我早期做API网关时踩过不少次。你想想看,前端发个请求过去,后端明明配了跨域头,结果浏览器还是给你拦下来。为啥?因为浏览器没搞清楚你是不是“安全”的请求。这个安全核查机制,就是预检请求(Preflight Request)。

说白了,浏览器在正式请求之前,先发一个OPTIONS请求去探路。服务器得给出明确答复——允许哪些源、哪些方法、哪些头。预检通过后,浏览器才放心地把真正的请求发出去。

我记得有次线上排查跨域问题,查了半天发现是后端的跨域头没写对,预检直接挂了,业务请求全被拦截。嗯,从那以后我就把预检机制研究透了。

核心结论:预检请求是浏览器的安全机制,不是HTTP协议强制的,但用了CORS跨域时,浏览器一定会先发OPTIONS来确认权限。

什么时候会触发预检?

并非所有跨域请求都会触发预检。浏览器把请求分成两类:简单请求和复杂请求。简单请求直接发,不预检;复杂请求必须先过预检这一关。

分类 触发条件 是否需要预检
简单请求
  • 方法:GET、HEAD、POST
  • Content-Type:application/x-www-form-urlencoded、multipart/form-data、text/plain
  • 没有自定义请求头
否
复杂请求
  • 方法:PUT、DELETE、PATCH 等
  • Content-Type:application/json、application/xml 等
  • 包含自定义请求头(如 Authorization、X-Requested-With)
是
个人习惯:我在设计API网关时,干脆把所有跨域请求都走预检。虽然多一次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。
我曾经踩过的坑:有一次配置了Access-Control-Allow-Headers,但漏掉了Authorization头,前端带着JWT令牌请求,预检直接返回403。排查了一下午……后来我养成了习惯:把所有可能用到的自定义头都列进去,省得漏。

预检请求的完整流程

用一张图把整个流程说清楚:

预检请求(OPTIONS)完整流程 浏览器 API网关 后端服务 OPTIONS请求 转发OPTIONS 204 + CORS头 204 + CORS头 网关校验Origin、 Method、Headers 通过 ✓ 拒绝 ✗ 浏览器发起真实请求 正常响应业务数据 返回403 Not Allowed 浏览器终止后续请求 说明 网关可以预置跨域规则,也可以动态探测可信源。 建议将OPTIONS请求直接返回204,不传给后端,减少链路开销。

你看流程图里,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
注意这个坑:Access-Control-Allow-Credentials设为true时,Access-Control-Allow-Origin不能是 *,必须指定具体源。否则浏览器会直接拒绝。我在项目中被这个规则坑过两次——第一次是没注意,第二次是改了但没重启网关……

实际编码: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手动发OPTIONS请求来验证跨域配置:
curl -X OPTIONS -H "Origin: https://frontend.com" -H "Access-Control-Request-Method: POST" https://api.example.com/resource -v

预检请求机制,说白了就是浏览器和API网关之间的一次握手。你只要记住:复杂请求必须先过OPTIONS这一关;网关要在这一关就把不安全的请求拒绝掉,而不是让真正的请求打到后端。这样一来,安全、性能和可维护性都有了保障。