問題描述
前后端分離的項目,前端使用Vue,后端使用Spring MVC。
顯然,需要解決瀏覽器跨域訪問數據限制的問題,在此使用CROS協議解決。
由於該項目我在中期加入的,主要負責集成shiro框架到項目中作為權限管理組件,之前別的同事已經寫好了部分接口,我負責寫一部分新的接口。
之前同事解決跨域問題使用Spring提供的@CrossOrigin注解:
@RequestMapping(value = "/list.do", method = RequestMethod.GET)
@ResponseBody
@CrossOrigin(origins="*")
@RequiresPermissions({"edge:manage"})
public JSONObject deviceList(HttpServletRequest request, HttpServletResponse response) throws Exception {
// do something
return new Object();
}
我進入項目的時候覺得這種方式太繁瑣了,需要在每一個Controller方法中都明確使用@CrossOrigin注解。
於是,我就使用Filter的方式解決我新寫的這部分接口,如下:
public class CROSFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest)request;
HttpServletResponse resp = (HttpServletResponse)response;
String origin = req.getHeader("Origin");
if(origin == null) {
String referer = req.getHeader("Referer");
if(referer != null) {
origin = referer.substring(0, referer.indexOf("/", 7));
}
}
resp.setHeader("Access-Control-Allow-Origin", origin); // 允許指定域訪問跨域資源
resp.setHeader("Access-Control-Allow-Credentials", "true");
if(RequestMethod.OPTIONS.toString().equals(req.getMethod())) {
String allowMethod = req.getHeader("Access-Control-Request-Method");
String allowHeaders = req.getHeader("Access-Control-Request-Headers");
resp.setHeader("Access-Control-Max-Age", "86400"); // 瀏覽器緩存預檢請求結果時間,單位:秒
resp.setHeader("Access-Control-Allow-Methods", allowMethod); // 允許瀏覽器在預檢請求成功之后發送的實際請求方法名
resp.setHeader("Access-Control-Allow-Headers", allowHeaders); // 允許瀏覽器發送的請求消息頭
return;
}
chain.doFilter(request, response);
}
}
OK,到目前為止,訪問我新寫的接口沒任何問題,但是訪問同事之前寫好的接口,在瀏覽器console中報錯:
Failed to load http://10.100.157.34:8080/devicemanager/device/list.do: The 'Access-Control-Allow-Origin' header contains
multiple values 'http://192.168.252.138:8000, http://192.168.252.138:8000', but only one is allowed.
Origin 'http://192.168.252.138:8000' is therefore not allowed access.
main.js:162 Error: Network Error
at FtD3.t.exports (createError.js:16)
at XMLHttpRequest.f.onerror (xhr.js:87)
根據日志描述,客戶端報錯是因為服務端返回的響應消息頭Access-Control-Allow-Origin包含了2個值。

錯誤原因
項目中涉及跨域訪問數據的問題,同時還需要跨域傳遞Cookie,根據CROS協議的規定,響應消息頭Access-Control-Allow-Origin值只能為指定單一域名(注:不能為通配符“*”)。
但是,現在服務端返回的響應消息頭Access-Control-Allow-Origin包含了多個值,客戶端認為不符合CROS協議,所以報錯。
那為什么會返回多個值呢?是因為請求在我寫的Filter中已經設置了一次,而到Controller方法時又通過Spring的@CrossOrigin注解添加了一次。
解決辦法
既然是同一個消息頭返回了多個值不合法,那么就需要控制服務端只能返回一個值,這是解決問題的思路和方向。
顯然,在Filter中是不能達到這個目的的。
1.使用Spring攔截器修改響應消息頭
第一個想法是通過自定義攔截器實現在Controller方法執行完畢之后修改響應消息頭值,其他不做任何修改。
public class CrossFilter extends HandlerInterceptorAdapter {
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception {
// 如果已經設置了消息頭,確保只設置一個值
String originHeader = "Access-Control-Allow-Origin";
if(response.containsHeader(originHeader)) {
String origin = request.getHeader("Origin");
if(origin == null) {
String referer = request.getHeader("Referer");
if(referer != null) {
origin = referer.substring(0, referer.indexOf("/", 7));
}
}
response.setHeader("Access-Control-Allow-Origin", origin);
}
String credentialHeader = "Access-Control-Allow-Credentials";
if(response.containsHeader(credentialHeader)) {
response.setHeader("Access-Control-Allow-Credentials", "true");
}
}
}
在Spring中添加攔截器配置:
<!-- 攔截器:對特定路徑進行攔截 -->
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**" />
<bean class="org.chench.test.filter.CrossFilter" />
</mvc:interceptor>
</mvc:interceptors>
但是,調試時發現:雖然在postHandle方法中已經明確設置了消息頭為一個值,但是返回到瀏覽器客戶端的依然是2個值!
百思不得解!
於是開始Google相關問題,終於找到了一篇博文:https://mtyurt.net/2015/07/20/spring-modify-response-headers-after-processing/。
博主也是想在Controller方法執行之后添加響應消息頭,但是采用Spring攔截器的方式也是不生效。
真正的原因是SpringMVC框架的限制,詳見:https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc。
在Spring的文檔中搜索關鍵字:postHandle,看到如下聲明:
Note that postHandle is less useful with @ResponseBody and ResponseEntity methods for which a the response is written
and committed within the HandlerAdapter and before postHandle. That means its too late to make any changes to the
response such as adding an extra header. For such scenarios you can implement ResponseBodyAdvice and either declare it as
an Controller Advice bean or configure it directly on RequestMappingHandlerAdapter.
What?原來是因為@ResponseBody注解的原因,導致無法通過攔截器的方式實現修改響應消息頭的目的。
2.在ResponseBodyAdvice中修改響應消息頭
由於Controller方法中已經使用了@ResponseBody注解返回json數據,故不能通過Spring攔截器修改響應消息頭。
但是Spring同時還提供了一個ResponseBodyAdvice接口,允許在這種場景下實現對響應消息頭的控制。
@ControllerAdvice
public class HeaderModifierAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request,
ServerHttpResponse response) {
ServletServerHttpRequest ssReq = (ServletServerHttpRequest)request;
ServletServerHttpResponse ssResp = (ServletServerHttpResponse)response;
if(ssReq == null || ssResp == null
|| ssReq.getServletRequest() == null
|| ssResp.getServletResponse() == null) {
return body;
}
// 對於未添加跨域消息頭的響應進行處理
HttpServletRequest req = ssReq.getServletRequest();
HttpServletResponse resp = ssResp.getServletResponse();
String originHeader = "Access-Control-Allow-Origin";
if(!resp.containsHeader(originHeader)) {
String origin = req.getHeader("Origin");
if(origin == null) {
String referer = req.getHeader("Referer");
if(referer != null) {
origin = referer.substring(0, referer.indexOf("/", 7));
}
}
resp.setHeader("Access-Control-Allow-Origin", origin);
}
String credentialHeader = "Access-Control-Allow-Credentials";
if(!resp.containsHeader(credentialHeader)) {
resp.setHeader(credentialHeader, "true");
}
return body;
}
}
OK,完美解決!
當然,對應我寫的Filter還需要對應調整一下:
public class CROSFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if(logger.isDebugEnabled()) {
logger.debug(String.format("CORS filter do filter"));
}
// 不再對所有請求都添加跨域消息頭
// 在Filter中只對OPTIONS請求進行處理,跨域消息頭放在ResponseBodyAdvice中解決
if(RequestMethod.OPTIONS.toString().equals(req.getMethod())) {
HttpServletRequest req = (HttpServletRequest)request;
HttpServletResponse resp = (HttpServletResponse)response;
String origin = req.getHeader("Origin");
resp.setHeader("Access-Control-Allow-Origin", origin); // 允許指定域訪問跨域資源
resp.setHeader("Access-Control-Allow-Credentials", "true");
String allowMethod = req.getHeader("Access-Control-Request-Method");
String allowHeaders = req.getHeader("Access-Control-Request-Headers");
resp.setHeader("Access-Control-Max-Age", "86400"); // 瀏覽器緩存預檢請求結果時間,單位:秒
resp.setHeader("Access-Control-Allow-Methods", allowMethod); // 允許瀏覽器在預檢請求成功之后發送的實際請求方法名
resp.setHeader("Access-Control-Allow-Headers", allowHeaders); // 允許瀏覽器發送的請求消息頭
return;
}
chain.doFilter(request, response);
}
}
總結
1.對於項目中需要解決瀏覽器跨域問題的方案應該統一,要么使用Filter方式,要么使用@CrossOrigin注解,這個必須一開始就全局統一規划好。
而我不得不使用上述方式解決問題,是因為前期已經寫好了很多代碼,不希望再去修改,不得已而為之。
2.對於使用了@ResponseBody注解的場景,如果需要統一調整響應消息頭,只能通過自定義ResponseBodyAdvice實現來完成。
3.建議通過Filter方式解決跨域問題,而不要直接使用Spring的注解@CrossOrigin,太繁瑣。
【參考】
http://www.cnblogs.com/nuccch/p/7875189.html 跨域請求傳遞Cookie問題
https://www.w3.org/TR/cors/ Cross-Origin Resource Sharing
https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc SpringMVC文檔
