Spring Boot跨域问题详解:四种解决方案与实战避坑指南
跨域问题从第一次遇到时一头雾水到后来一眼看出原因几乎每个做前后端分离项目的Java开发都经历过。不管你是刚接触Spring Boot的新人还是在老项目里被跨域折磨过几次的老手这篇文章要聊的就是Spring Boot里解决跨域的四种常用方式以及我在实际项目中踩过的坑和总结出来的选型经验。先说清楚这篇文章能帮你解决什么问题彻底理解跨域产生的底层原因掌握Spring Boot中四种常见的跨域解决方案每种方案的适用场景、优缺点、配置细节一次说清以及最重要的避开那些“配置了但还是报错”的隐蔽坑。如果你是后端开发这篇文章可以直接当作参考手册用如果你刚接触前后端联调也能通过这篇文章搞清楚跨域的来龙去脉。1. 先搞懂跨域到底是怎么发生的很多人一遇到跨域就急着找解决方案却不太清楚跨域的本质。跨域这个词听起来像“跨越地域”实际上它和物理地域没有任何关系它指的是浏览器的一个安全策略叫作同源策略。1.1 同源策略与“源”的判定标准同源策略是浏览器默认开启的一套安全机制它的核心规则是页面只能读取和操作“同源”的资源。这里的“源”由三个部分共同决定协议、域名、端口。三者完全一致才算同源。举几个直观的例子假设当前页面地址是http://localhost:8080/index.html目标地址是否同源原因http://localhost:8080/api/data同源协议、域名、端口全部一致https://localhost:8080/api/data跨域协议不同http vs httpshttp://localhost:8081/api/data跨域端口不同8080 vs 8081http://127.0.0.1:8080/api/data跨域域名不同localhost vs 127.0.0.1http://api.example.com:8080/api/data跨域域名不同注意最后一条很多新人容易忽略localStorage和127.0.0.1虽然都指向本机但在浏览器的判定里它们是两个完全不同的域名照样跨域。前端开发环境里最常见的情况是前端跑在http://localhost:5173后端跑在http://localhost:8080端口不同必然跨域。还有一种情况是前端域名是www.example.com后端接口域名是api.example.com子域名不同也跨域。1.2 简单请求与预检请求Preflight跨域请求并非一刀切地禁止浏览器在执行时会先分个类简单请求和非简单请求。简单请求需要同时满足几个条件请求方法是GET、POST或HEAD之一请求头只能包含几个常规字段如Accept、Content-Type、Accept-Language等Content-Type限制在text/plain、multipart/form-data或application/x-www-form-urlencoded。一旦请求不满足简单请求的条件浏览器就会先发出一个OPTIONS请求来“探路”这个请求就是预检请求。预检请求的响应头里会携带服务端允许的跨域策略浏览器验证通过后才正式发送真实请求。这里有个实际开发中高频出现的痛点前端用axios的Content-Type: application/json发POST请求再带一个自定义token头浏览器会判定这属于非简单请求自动先发一个OPTIONS。很多后端开发没见过OPTIONS请求以为被攻击了实际上这是浏览器的正常行为。理解了这层原理你在排查跨域问题时就能更快定位问题点。后端解决跨域的核心思路就是让服务端的响应头携带允许跨域的规则或者直接拦截并处理OPTIONS预检请求。2. Spring Boot解决跨域的四种核心方式在Spring Boot里解决跨域主流方式就是四种CrossOrigin注解、全局CORS配置、CorsFilter过滤器、以及自定义过滤器。下面逐一拆解每种都会给出完整可运行的代码示例、配置说明和注意事项。2.1 方式一CrossOrigin注解CrossOrigin是Spring自带的注解可以直接加在Controller类上也可以加在具体的接口方法上。它属于细粒度的局部跨域控制是四种方式里代码侵入最小、最容易上手的一种。RestController CrossOrigin(origins http://localhost:5173) public class UserController { GetMapping(/user/info) public UserInfo getUserInfo() { // 业务逻辑 return userInfo; } }也支持加在方法上对单个接口生效RestController public class UserController { GetMapping(/user/info) CrossOrigin(origins http://localhost:5173, maxAge 3600) public UserInfo getUserInfo() { // 业务逻辑 return userInfo; } }CrossOrigin注解常用属性属性名作用默认值origins允许访问的源列表可以是具体域名或**allowedHeaders允许的请求头列表*methods允许的请求方法列表默认为注解所在方法支持的请求方式allowCredentials是否允许携带凭证Cookie等空字符串表示未设置实际上默认为falsemaxAge预检请求的缓存时间秒1800秒提示allowCredentials true时origins不能配置为*。浏览器规范强制要求如果需要携带凭证源必须明确指定为具体域名。这也是实际开发中容易踩坑的点之一。这种方式的优点很直接无需全局生效只对指定接口开放跨域权限对于外部开放接口较少的项目非常合适。缺点是每个需要跨域的Controller或方法都要加注解如果项目里几十个接口都要跨域重复代码会明显增多维护起来也让人头大。我的经验是一个项目如果只有两三个跨域接口用注解最省事只要超过五六个接口就该考虑全局方案。2.2 方式二全局CORS配置WebMvcConfigurer全局CORS配置的核心思路是实现WebMvcConfigurer接口重写addCorsMappings方法将跨域规则一次性应用到指定路径上。这是我个人在正式项目中使用频率最高的一种方式。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5173, https://www.example.com) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }参数说明逐行拆一遍addMapping(/**)/**表示对所有接口路径生效也可以写成/api/**只对特定前缀的接口生效。allowedOrigins(...)指定允许的来源地址。可以写多个也可以写*。注意一旦allowCredentials(true)这里就必须写具体来源不能写*。allowedMethods(...)只列出业务需要的方法即可但建议把OPTIONS加进去。虽然某些情况下不写也能工作但显式加上可以避免一些框架层面的误拦截。allowedHeaders(*)放行所有请求头。如果前端有自定义请求头比如token字段通常写*最省心或者把需要的头名写全。maxAge(3600)预检请求结果缓存3600秒。这个数值调大一点能显著减少OPTIONS请求次数提升联调阶段的接口响应效率。这种方式依然是Spring MVC层面做的处理具体机制是Spring在内部构建一个CorsInterceptor对所有命中的请求执行跨域逻辑。它的优点非常多一处配置全局生效不需要侵入业务代码后续要调整来源只需要改一个配置类。实际项目中我一般这样用开发环境直接用allowedOrigins(*)同时注意不开启allowCredentials或维持默认关闭生产环境则把来源收敛成线上域名白名单。这样做的好处是整个团队在联调前不需要来回改代码只需在配置中心或者环境配置里改来源地址就行。2.3 方式三注册CorsFilter过滤器CorsFilter是Spring Web提供的一个过滤器它的配置思路和方式二类似但不依赖MVC层而是走Servlet过滤器链路。关键区别在于过滤器链路比Spring MVC的处理器映射链路更早执行在一些特殊场景下CorsFilter反而比addCorsMappings更可靠。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:5173); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }看到这里你可能会问方式和方式二三看起来高度相似核心区别到底是什么区别在于生效位置和执行时机。方式二是在Spring MVC的处理器执行链中通过拦截器实现的它位于DispatcherServlet之后、Controller之前方式三是Servlet标准的过滤器它在请求进入DispatcherServlet之前就会执行。说得直白一点CorsFilter的拦截位置更靠外层能够更早地处理预检请求和跨域响应头。在项目中如果还引入了Spring Security或者其他安全框架过滤器的执行顺序往往会影响跨域是否能正常工作。使用CorsFilter并通过FilterRegistrationBean设置setOrder(Ordered.HIGHEST_PRECEDENCE)可以让跨域处理最先执行避免被后续安全过滤器拦截。另外CorsFilter的优势在于它使用的是Servlet规范接口。假如项目的某些接口最终要走网关统一鉴权、或者存在多个Spring MVC上下文CorsFilter在兼容性和可控性上会稍微强一些。缺点是配置代码稍长而且需要理解过滤器执行顺序的概念。对于引入Spring Security的项目我通常会选择这个方式配合最高优先级注册来避免一连串跨域失效的连锁反应。2.4 方式四自定义Filter底层实现前三种方式都是基于Spring和Servlet提供的现成能力。但存在一些特殊业务场景比如需要根据请求来源动态判断是否允许跨域例如判断来源IP是否来自内部网段或者需要把CORS逻辑和自定义的业务逻辑比如数据签名校验打包在一起。这时候手写一个Filter就是最灵活的方式。Component public class CorsFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; HttpServletResponse httpResponse (HttpServletResponse) response; String origin httpRequest.getHeader(Origin); if (origin ! null allowedOrigins.contains(origin)) { httpResponse.setHeader(Access-Control-Allow-Origin, origin); httpResponse.setHeader(Access-Control-Allow-Credentials, true); httpResponse.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); httpResponse.setHeader(Access-Control-Allow-Headers, Origin, Content-Type, Accept, Authorization, token); httpResponse.setHeader(Access-Control-Max-Age, 3600); } // 处理预检请求直接返回不进入业务代码 if (OPTIONS.equalsIgnoreCase(httpRequest.getMethod())) { httpResponse.setStatus(HttpServletResponse.SC_OK); return; } chain.doFilter(request, response); } }这个实现里有几个细节值得说明。Access-Control-Allow-Origin设置成变量origin而不是写死的*这样做可以配合Allow-Credentials: true工作因为*和credentials在浏览器规范里是互斥的。Access-Control-Allow-Headers里把所有可能用到的头都列出来尤其是带token验证的项目这列里必须有对应的自定义token头名否则预检请求会失败。OPTIONS请求直接return不走业务逻辑这是跨域处理的原则因为预检请求本来就不该触发后端方法调用。自定义Filter最大的价值在于你可以往里面加任意的业务判断。比如我曾在某个项目中做过一个逻辑——只允许来自合作方固定域名的请求跨域调用某个开放接口其他来源一律拒绝。这种需求用Spring的注解或者全局配置也可以实现比如通过allowedOriginPatterns配合额外的校验但写Filter的时候调整起来更直接。同时也要提醒一句手写Filter需要你自己处理很多边界情况比如缺少Origin头的请求、服务端异常时响应头是否丢失、多个Filter并发时的线程安全问题。如果可以优先考虑Spring现成的方案这个方式更适合作为兜底方案来理解和掌握。3. 四种方式对比与选型建议直接上对比表格一眼看清各自的定位。对比维度CrossOrigin注解WebMvcConfigurerCorsFilter自定义Filter配置范围局部类或方法全局全局完全自定义代码侵入性低但重复率高低一处配置低一个Bean中等框架兼容性仅Spring MVC仅Spring MVCServlet标准Servlet标准与Spring Security配合容易被安全链拦截容易被安全链拦截可设置最高优先级可设置最高优先级支持动态判断不支持不支持弱支持强支持代码量最少少中等较多学习成本最低低中中选型从来不是“哪个最厉害”而是“哪个最合适”。我的建议是分情况处理开发环境优先考虑WebMvcConfigurer全局配置。理由很简单配置少、代码清晰、团队里的新人也容易看懂。绝大多数前后端分离项目用这种方式就够了不需要为了“显得高级”去引入更复杂的实现。如果项目集成了Spring Security优先尝试CorsFilter。因为我实际遇到过用WebMvcConfigurer配置了跨域但前端请求还是报跨域错误排查到最后发现是Spring Security的过滤器链在进入MVC层之前就把OPTIONS请求拦截了。改成CorsFilter并把它放在过滤器链最前面问题直接解决。如果只有两三个外部接口需要跨域CrossOrigin注解就够了。虽然全局配置更加统一但为了几个接口增加配置类有点杀鸡用牛刀。而且注解可以精确到方法级别误放行的风险更小。如果存在网关转发、多服务之间联动、动态来源判断等复杂场景那就用自定义Filter。尤其是多环境部署时不同环境的允许来源不一样手写Filter读取配置中心的来源列表再动态判断比改代码重新发布优雅得多。4. 实战中的常见问题与排错心得跨域问题本身不复杂但排查起来却能耗尽一个下午。我把这些年真实遇到过的典型问题做了个汇总每一条都是踩过坑之后的沉淀。4.1 配置了还是报跨域问题出在哪这是出现频率最高的问题。配置明明写对了前后端却还是报跨域一般从这几个方向排查排查方向具体检查项说明请求是否为预检请求浏览器Network里是否有OPTIONS请求检查后端是否正确处理了OPTIONS过滤器执行顺序跨域过滤器是否在安全过滤器之后被安全过滤器拦截后响应头根本加不上是否存在网关层网关是否统一配置跨域网关加过一次头后后端再添加可能被合并或被丢弃Cookie携带问题前端的credentials配置是否与后端匹配后端不允许credentials但前端带了凭证会报错代理环境干扰Nginx等反向代理是否配置了add_header代理层和后端配置不一致时跨域头会被覆盖我遇到过一个很隐蔽的案例项目有网关网关已经配了全局跨域后端又配了WebMvcConfigurer。正常情况下两层配置只要一致就没问题但那次的场景是网关层配置了Access-Control-Allow-Origin: *后端又按具体域名设置的跨域结果浏览器收到的响应头被网关覆盖成了*。这个时候前端明明没有带Cookie却报错了因为网关把后端的credentials配置也覆盖掉了。排查了半天才定位到问题出在网关配置覆盖。遇到这种多层级架构先把跨域处理的层级理清楚避免每层都做一遍。4.2 axios带token导致预检请求失败前端项目里axios带着自定义token调用GET接口是常规操作但很多后端同学会忽略前端请求头里带了自定义字段这件事。报错场景是这样的跨域配置看起来全对接口文档里要求前端传token实际联调时请求一直报CORS error。打开浏览器的Network面板发现预检请求OPTIONS的响应头是404或者403。排查后发现原因在于一些后端配置只允许了Content-Type、Accept等常规头没有在allowedHeaders里把自定义的token头加上。浏览器预检时会检查“允许的请求头列表”如果里面没有token预检直接失败真实请求根本不会发出去。解决办法很简单在后端跨域配置中把token头加进allowedHeaders或者直接写*。我在项目中为了让前端联调方便通常建议开发环境直接写*生产环境再把需要的头名收敛出来。4.3 allowCredentials与allowedOrigins的冲突这个坑已经在前面提过多次但值得单独拎出来再强调一次。浏览器规范规定Access-Control-Allow-Origin不能是*的同时又能允许携带Credentials。也就是说如果你配置了allowCredentials(true)那么allowedOrigins必须写具体域名。有同事遇到过这么个诡异现象在本地联调时一切正常部署到测试环境就报错。查了很久发现测试环境配置中把来源写成了*但因为开发的时候本地用的是localhost:5173这样的具体地址所以没暴露这个问题。把*改成测试环境的具体域名后问题立刻消失。注意如果你希望允许任意来源且携带Cookie可以考虑allowedOriginPatterns写法。在Spring的较新版本中allowedOriginPatterns(*)配合allowCredentials(true)是允许的它会在响应头中返回实际的Origin而不是*这样既满足业务需求又能避开浏览器的规范限制。4.4 拦截器抛出异常导致跨域失效有个容易忽略的细节跨域响应头是通过一个CORS处理器写进响应里的但如果你在拦截器里抛出了业务异常而这个异常又被统一异常处理器捕获了响应头的添加时机可能已经被提前终止这时候返回给前端的错误响应里自然找不到Access-Control-Allow-Origin头。这种情况的典型表现是请求正常时接口返回数据没问题一旦接口参数校验不通过或业务异常前端就报跨域而不是报业务错误。遇到这种问题把跨域配置处理优先级提前或者确保异常处理器输出响应时不要覆盖掉已有的跨域头基本就能解决。4.5 生产环境建议把跨域头前移到Nginx层如果是传统部署方式前端资源由Nginx托管后端接口也是通过Nginx反向代理出去的那么把跨域配置直接放在Nginx层其实是最省事、性能也最好的方案。Nginx配置参考server { listen 80; server_name api.example.com; location / { add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Credentials true; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS; add_header Access-Control-Allow-Headers Origin, Content-Type, Accept, Authorization, token; add_header Access-Control-Max-Age 3600; if ($request_method OPTIONS) { return 204; } proxy_pass http://backend_servers; } }加一行$http_origin的好处是Nginx会动态获取请求的Origin来源来设置Allow-Origin头。这样配合第三方的带凭证请求时也能规避*和credentials互斥的问题。这里给一个我个人的判断标准如果项目只有一套Web应用后端全局配置方案完全足够如果项目是微服务架构多个服务多个域名那跨域头应该在网关层统一处理子服务不再单独配置如果项目用了Nginx做静态托管加反向代理优先把跨域放到Nginx层。5. 一些日常开发中的实操心得最后分享几个我多年项目里留存的实操经验纯个人体会但能帮你少走不少弯路。开发环境出现跨域报错时先别急着改代码。打开浏览器的Network面板找到那条红色请求看它的请求方式是OPTIONS还是实际请求。如果是OPTIONS说明浏览器预检都没通过问题大概率在跨域配置里头的允许列表、请求头配置上。如果预检通过了但实际请求报错再往后端业务逻辑里排查。这一点判断思路能快速缩小问题范围。关于预检请求的缓存时间maxAge建议按场景调整。开发环境缓存时间太长一旦改了跨域配置浏览器还在用旧缓存反而会影响联调。通常建议开发环境设短一点比如60秒生产环境再设长一点比如3600秒。有些项目上线后会发现OPTIONS请求量特别大就是maxAge没设或者设得太短造成的。还有一个容易被忽视的点跨域配置涉及安全边界上线前一定要把allowedOrigins从*收敛成真实域名白名单。虽然开放所有来源在功能上没问题但这就意味着任何网站都能向你的后端发请求。如果接口没有做好身份校验就相当于对外裸奔。生产环境中我建议把跨域配置里的来源白名单放在配置中心管理这样调整域名时不用重新部署、改代码。我之前负责过一个对外提供API的服务项目接口没有做跨域限制因为它的调用方都是后端服务而不是浏览器跨域策略根本用不上。所以说到底跨域问题本质上是浏览器的问题只有浏览器发起的请求才需要CORS处理。理解了这一点你会发现自己排查问题的方向清晰很多。判断请求是否需要跨域配置先看调用方是浏览器还是服务端后者直接不用管。