CrossOrigin
本章聚焦 Spring MVC 的跨域请求处理。现代 Web 应用普遍采用前后端分离架构,前端页面部署在一个域名(如
http://localhost:3000),后端 API 部署在另一个域名(如http://api.feixiang.com)。浏览器的同源策略会阻止这种跨域 AJAX 请求,而@CrossOrigin注解正是 Spring MVC 提供的细粒度跨域解决方案。本章与全局的关系:前面章节讲解了 WebMvcConfigurer 的全局跨域配置,本章讲解方法级和类级的细粒度跨域控制。两者互补,共同构成完整的 CORS 策略体系。
定义与作用
@CrossOrigin 是 Spring MVC 提供的跨域注解,标注在 Controller 类或方法上,允许指定来源(origin)的浏览器发起跨域 AJAX 请求。它通过设置 CORS(Cross-Origin Resource Sharing)响应头,告诉浏览器"我允许这个域名的页面访问我"。
核心原理:浏览器在发送跨域请求前,会先发送一个 Preflight 预检请求(OPTIONS 方法),询问服务器是否允许跨域。服务器返回的响应头中包含允许的域名、方法、头信息等。浏览器根据响应决定是否发送真正的请求。
生活类比:公司访客登记系统
想象飞翔科技的大楼门禁:
- 正常情况下,只有本大楼员工(同源)能自由进出
- 外公司人员(跨域)想进来办事,需要提前申请访客许可
- @CrossOrigin 就像"访客许可证"——你可以指定"允许哪家公司的人来访"(origins)、"允许他们办什么事"(methods)、"允许他们带什么物品"(headers)、"许可证有效期多久"(maxAge)
- 没有许可证?保安(浏览器)直接拦在门外
关键认知:CORS 是服务器授权 + 浏览器执行的安全机制。服务器通过响应头表示"我同意",浏览器负责"强制执行"。如果服务器说同意但浏览器不执行,或者浏览器执行但服务器不同意,跨域请求都会失败。
核心原理
跨域请求完整流程
简单请求 vs 预检请求:
| 类型 | 条件 | 流程 |
|---|---|---|
| 简单请求 | GET/HEAD/POST + 标准头 + 无自定义头 | 直接发送,服务器返回 Access-Control-Allow-Origin |
| 预检请求 | PUT/DELETE/PATCH + 自定义头 + 非标准 Content-Type | 先发送 OPTIONS 预检,通过后再发真实请求 |
@CrossOrigin 生效位置
合并规则:方法级 @CrossOrigin 的属性覆盖类级 @CrossOrigin 的同名属性。如果方法级未指定某属性,则继承类级的该属性。
适用位置与常用属性
@CrossOrigin 可以标注在类和方法上:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
origins / value | String[] | *(允许所有) | 允许的来源域名,如 "http://localhost:3000" |
allowedHeaders | String[] | *(允许所有) | 允许的请求头,如 "X-Token", "Content-Type" |
methods | RequestMethod[] | 同 @RequestMapping | 允许的 HTTP 方法,如 GET, POST |
exposedHeaders | String[] | 无 | 允许客户端访问的响应头 |
allowCredentials | String | "" | 是否允许携带 Cookie,"true" 或 "false" |
maxAge | long | -1(不缓存) | 预检请求缓存时间(秒) |
标注位置
| 位置 | 作用范围 | 示例 |
|---|---|---|
| 类上 | 该类所有方法 | @CrossOrigin(origins = "http://localhost:3000") |
| 方法上 | 仅该方法 | @CrossOrigin(origins = "*", maxAge = 3600) |
| 类 + 方法 | 合并配置,方法优先 | 类设 origins,方法设 maxAge |
完整示例
场景
飞翔科技员工管理系统的前端由黄俪开发,部署在 http://localhost:3000。后端 API 部署在 http://localhost:8080。需要配置 CORS 允许前端跨域访问。
项目结构
employee-web/
├── src/main/java/
│ └── com/feixiang/web/
│ └── controller/
│ └── EmployeeController.java
Controller 实现
// EmployeeController.java
package com.feixiang.web.controller;
import com.feixiang.web.dto.EmployeeDTO;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
@RestController
@RequestMapping("/api/employees")
@CrossOrigin(origins = "http://localhost:3000", allowedHeaders = "*", allowCredentials = "true")
public class EmployeeController {
@GetMapping
public List<EmployeeDTO> list() {
EmployeeDTO e1 = new EmployeeDTO();
e1.setName("张三");
e1.setDepartment("研发部");
EmployeeDTO e2 = new EmployeeDTO();
e2.setName("李四");
e2.setDepartment("产品部");
return Arrays.asList(e1, e2);
}
@PostMapping
@CrossOrigin(origins = "*", maxAge = 3600) // 方法级覆盖类级的 origins
public EmployeeDTO add(@RequestBody EmployeeDTO dto) {
dto.setName(dto.getName() + "-已保存");
return dto;
}
@DeleteMapping("/{id}")
@CrossOrigin(origins = "http://localhost:3000", methods = RequestMethod.DELETE)
public String delete(@PathVariable Long id) {
return "删除成功: " + id;
}
}
HTTP 请求示例 1:简单跨域 GET 请求
前端 JavaScript:
fetch('http://localhost:8080/api/employees', {
method: 'GET',
credentials: 'include'
});
对应的 curl:
$ curl -X GET http://localhost:8080/api/employees \
-H "Origin: http://localhost:3000" \
-v
响应:
< HTTP/1.1 200
< Access-Control-Allow-Origin: http://localhost:3000
< Access-Control-Allow-Credentials: true
< Vary: Origin
< Content-Type: application/json
<
[{"name":"张三","department":"研发部"},{"name":"李四","department":"产品部"}]
流程解析:
- 浏览器发现请求是跨域的(Origin 与目标域名不同)
- 这是 GET 请求,属于简单请求,直接发送
- 服务器返回
Access-Control-Allow-Origin: http://localhost:3000 - 浏览器检查响应头,发现 Origin 被允许,把数据交给前端 JS
HTTP 请求示例 2:预检请求(PUT + 自定义头)
前端 JavaScript:
fetch('http://localhost:8080/api/employees', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Token': 'abc123'
},
body: JSON.stringify({name: '王五', department: '测试部'}),
credentials: 'include'
});
对应的 curl(模拟预检):
$ curl -X OPTIONS http://localhost:8080/api/employees \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: X-Token" \
-v
预检响应:
< HTTP/1.1 204
< Access-Control-Allow-Origin: *
< Access-Control-Allow-Methods: GET,HEAD,POST
< Access-Control-Allow-Headers: X-Token
< Access-Control-Max-Age: 3600
真实请求:
$ curl -X POST http://localhost:8080/api/employees \
-H "Origin: http://localhost:3000" \
-H "Content-Type: application/json" \
-H "X-Token: abc123" \
-d '{"name":"王五","department":"测试部"}'
响应:
{"name":"王五-已保存","department":"测试部"}
流程解析:
- 浏览器发现 POST + 自定义头
X-Token,触发预检请求 - 发送 OPTIONS 询问服务器是否允许
- 服务器根据
@CrossOrigin配置返回允许的头和方法 - 浏览器缓存预检结果(3600 秒内不再重复预检)
- 发送真实的 POST 请求
@CrossOrigin vs CorsRegistry 全局配置对比
| 对比维度 | @CrossOrigin(注解) | CorsRegistry(全局配置) |
|---|---|---|
| 配置位置 | Controller 类/方法上 | WebMvcConfigurer.addCorsMappings |
| 粒度 | 细粒度,按接口控制 | 粗粒度,按路径模式控制 |
| 灵活性 | 与业务代码耦合 | 与业务代码解耦 |
| 维护性 | 分散在各 Controller | 集中在一处 |
| 适用场景 | 个别接口特殊跨域需求 | 整个项目统一跨域策略 |
| 优先级 | 方法级 > 类级 | 与 @CrossOrigin 共存时,注解优先 |
推荐实践
架构师白歌在飞翔科技制定了以下 CORS 策略:
// WebConfig.java —— 全局兜底配置
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
// AdminController.java —— 特殊接口额外限制
@RestController
@RequestMapping("/api/admin")
@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
public class AdminController {
@DeleteMapping("/users/{id}")
@CrossOrigin(origins = "http://localhost:3000", methods = RequestMethod.DELETE, maxAge = 7200)
public String deleteUser(@PathVariable Long id) {
return "删除用户: " + id;
}
}
策略说明:
- 全局配置覆盖 90% 的接口,维护简单
- 敏感接口(如删除操作)用
@CrossOrigin额外限制方法和缓存时间 - 两者共存时,
@CrossOrigin的属性覆盖全局配置的同名属性
易错场景与面试考点
误区一:origins = "*" 与 allowCredentials = "true" 同时使用
错误代码:
@CrossOrigin(origins = "*", allowCredentials = "true")
错误现象:浏览器报错:The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
纠正:当需要携带 Cookie(allowCredentials = "true")时,origins 不能为 *,必须显式指定具体的域名:
@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
这是浏览器的安全限制,不是 Spring 的限制。
误区二:CORS 配置正确但请求仍失败
排查清单:
- 检查是否是简单请求:如果是非简单请求,确认 OPTIONS 预检是否通过
- 检查响应头是否包含:
Access-Control-Allow-Origin必须存在且值正确 - 检查是否被拦截器拦截:如果预检请求被 LoginInterceptor 拦截返回 401,浏览器会报 CORS 错误(实际是先被拦截器拦了)
- 检查是否被 Filter 修改:某些安全 Filter 可能会在响应头之后追加头,导致 CORS 头丢失
关键经验:看到 CORS 错误时,先 curl 测试服务器是否返回了正确的 CORS 头。如果 curl 有头但浏览器报错,检查是否被拦截器/Filter 干扰。
误区三:maxAge 配置不生效
错误认知:"我设置了 maxAge = 3600,但浏览器每次还是发 OPTIONS。"
纠正:maxAge 只控制预检请求的缓存。简单请求(GET/POST)不需要预检,所以不涉及 maxAge。另外,浏览器对 maxAge 有上限(如 Chrome 最大 7200 秒),超过上限会被截断。
面试高频:CORS 和 JSONP 的区别
| 对比维度 | CORS | JSONP |
|---|---|---|
| 原理 | 服务器响应头授权 | 利用 <script> 标签不受同源限制 |
| 支持方法 | 所有 HTTP 方法 | 仅 GET |
| 安全性 | 高(服务器控制) | 低(XSS 风险) |
| 现代推荐 | ✅ 首选 | ❌ 已淘汰 |
标准回答:CORS 是 W3C 标准,通过服务器响应头实现跨域,支持所有 HTTP 方法,安全性高。JSONP 是历史 hack 方案,利用 script 标签绕过同源策略,只支持 GET,存在 XSS 风险,现代项目应使用 CORS。
面试高频:Spring MVC 如何处理 OPTIONS 预检请求
标准回答:Spring MVC 的 DispatcherServlet 接收到 OPTIONS 请求后,会查找匹配的 HandlerMapping。如果目标 Controller/方法上有 @CrossOrigin,AbstractHandlerMethodMapping 会构造一个特殊的 Handler(PreFlightHandler),直接返回 CORS 响应头,不执行实际的 Controller 方法。
小结
@CrossOrigin 是 Spring MVC 提供的细粒度跨域注解,通过设置 CORS 响应头,允许指定来源的浏览器发起跨域 AJAX 请求。它可以标注在 Controller 类上(影响所有方法)或方法上(仅影响该方法),方法级配置覆盖类级配置。与 WebMvcConfigurer 的全局 CORS 配置相比,@CrossOrigin 更适合个别接口的特殊跨域需求。
本章与全局的关系:本章讲解了方法级跨域控制。下一章"文件上传与异常处理"将讲解如何处理 multipart 文件上传请求,以及如何在 Controller 层面处理异常。