公司现有系统需要整合Spring Session + Spring data redis,整合后发现了一个问题,就是Spring session的 Cookie (名称为:SESSION无法写出到客户端浏览器,导致登录验证成功后又被LoginFilter验证再次强制重定向到登录页)。
查看Spring Session源码:发现写入Cookie都是通过以下方法实现的:
org.springframework.session.web.http.DefaultCookieSerializer.writeCookieValue
而这个方法什么时候会被调用呢?
调用链如下:
org.springframework.session.web.http.SessionRepositoryFilter#SessionRepositoryRequestWrapper的commitSession方法
调用:
org.springframework.session.web.http.CookieHttpSessionStrategy的OnNewSession方法
调用:
org.springframework.session.web.http.DefaultCookieSerializer的writeCookieValue方法
那么commitSession方法会在什么时候被调用呢?这个方法主要在2个地方调用:
第一个调用地方如下:
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
request, response, this.servletContext);
SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
wrappedRequest, response);
HttpServletRequest strategyRequest = this.httpSessionStrategy
.wrapRequest(wrappedRequest, wrappedResponse);
HttpServletResponse strategyResponse = this.httpSessionStrategy
.wrapResponse(wrappedRequest, wrappedResponse);
try {
filterChain.doFilter(strategyRequest, strategyResponse);
}
finally {
wrappedRequest.commitSession();
}
}
它在filter过滤链完成后被调用,而一般Spring Session的SessionRepositoryFilter建议是放在filter过滤链的第一个位置,那么上面方法在finlly中调用commitSession,将保证在最后执行过滤器SessionRepositoryFilter,commitSession一定会调用到。
虽然是放在过滤器的最后,一定会被调用,但是调用是否有效还要看情况,,调用是否有效取决于 response.isCommited()方法返回值的。
第二个调用地方:
private final class SessionRepositoryResponseWrapper
extends OnCommittedResponseWrapper {
private final SessionRepositoryRequestWrapper request;
/**
* Create a new {@link SessionRepositoryResponseWrapper}.
* @param request the request to be wrapped
* @param response the response to be wrapped
*/
SessionRepositoryResponseWrapper(SessionRepositoryRequestWrapper request,
HttpServletResponse response) {
super(response);
if (request == null) {
throw new IllegalArgumentException("request cannot be null");
}
this.request = request;
}
@Override
protected void onResponseCommitted() {
this.request.commitSession();
}
}
commitSession方法会被 OnResponseCommited方法调用,OnResponseCommited()是抽象模板方法,具体看SessionRepositoryResponseWrapper的父类:OnCommittedResponseRrapper类中的调用:
private void doOnResponseCommitted() {
if (!this.disableOnCommitted) {
onResponseCommitted();
disableOnResponseCommitted();
}
}
doOnResponseCommited在什么时候被调用呢?这个被调用地方比较多了:
//在OnCommittedResponseWrapper的以下这些方法中会执行doOnResponseCommitted()
@Override
public final void sendError(int sc, String msg) throws IOException {
doOnResponseCommitted();
super.sendError(sc, msg);
}
@Override
public final void sendRedirect(String location) throws IOException {
doOnResponseCommitted();
super.sendRedirect(location);
}
@Override
public void flushBuffer() throws IOException {
doOnResponseCommitted();
super.flushBuffer();
}
而OnCommittedResponseRrapper类的内部类SaveContextPrintWriter和SaveContextServletOutputStream相关方法也会调用:
//OnCommittedResponseRrapper的内部类SaveContextPrintWriter的以下方法会调用doOnResponseCommitted
//OnCommittedResponseRrapper的内部类SaveContextServletOutputStream的以下方法会调用doOnResponseCommitted
public void flush() throws IOException {
doOnResponseCommitted();
this.delegate.flush();
}
public void close() throws IOException {
doOnResponseCommitted();
this.delegate.close();
}
//另外这2个内部类的相关print/write方法也都会调用doOnResponseCommitted()
public void print(String s) throws IOException {
trackContentLength(s);
this.delegate.print(s);
}
private void trackContentLength(String content) {
checkContentLength(content.length());
}
//这个方法非常重要!!!
private void checkContentLength(long contentLengthToWrite) {
this.contentWritten += contentLengthToWrite;
boolean isBodyFullyWritten = this.contentLength > 0
&& this.contentWritten >= this.contentLength;
int bufferSize = getBufferSize();
boolean requiresFlush = bufferSize > 0 && this.contentWritten >= bufferSize;
if (isBodyFullyWritten || requiresFlush) {
doOnResponseCommitted();
}
}
注意了: 以上这些方法在真正执行flush, close, 或者 print, write等方法前,会先执行doOnResponseCommitted(); 保证Cookie写出操作在 内容输出前 被执行。
因为response.isCommited()方法如果返回为true,那么往response中写入cookie是无效的,无法写入到客户端浏览器。API说明如下:
那么可以得出以下结论:
writeCookieValue()方法最终是被 commitSession()方法给调用的,
而commitSession最终会被OnResponseCommited方法调用,
而OnResponseCommited方法会被 以下场景调用:
1. response.flushBuffer()
2. response.sendRedirect()
3. response.sendError()
4. response.getWriter()或者response.getOutputStream() 的 close()方法
5. response.getWriter()或者response.getOutputStream() 的 flush()方法
6. response.getWriter()或者response.getOutputStream() 的 print()或者write()等相关内容输出方法调用
好了,回到坑的问题,为什么cookie无法被写出。
系统使用了: struts-json-plugin.jar 做为struts2的json实现
而公司系统把登录请求被做成了一个ajax请求。
所有的正常txt/html等正常跳转(非ajax请求)都能正常的写出 SESSION Cookie.
先看下登录Action 返回的JSON内容:
再看下Struts-json-plugin是如何输出json的,具体参考:
org.apache.struts2.json.JSONUtil的writeJSONToResponse方法:
public static void writeJSONToResponse(SerializationParams serializationParams) throws IOException {
....
} else {
response.setContentLength(json.getBytes(serializationParams.getEncoding()).length);
PrintWriter out = response.getWriter();
out.print(json);
}
}
可以看出,writeJSONToResponse并没有主动调用close,或者 flush等方法。所以不会主动触发OnResponseCommited,
但是print和write方法其实也可以有条件性质的触发OnResponseCommited,如下面代码所示:
response.setContentLength 这个设置的值是 77,因为编码是UTF-8,返回结果中有4个汉字,1个汉字3个字节。
json.getBytes(serializationParams.getEncoding()).length 返回值是77.
而out.print(json)方法中会进行判断,代码如下:
public void print(String s) throws IOException {
trackContentLength(s);
this.delegate.print(s);
}
private void trackContentLength(String content) {
checkContentLength(content.length());
}
private void checkContentLength(long contentLengthToWrite) {
this.contentWritten += contentLengthToWrite;
boolean isBodyFullyWritten = this.contentLength > 0
&& this.contentWritten >= this.contentLength;
int bufferSize = getBufferSize();
boolean requiresFlush = bufferSize > 0 && this.contentWritten >= bufferSize;
if (isBodyFullyWritten || requiresFlush) {
doOnResponseCommitted();
}
}
可以看到这里是按 json的length()去计算,计算出来contentLengthToWrite是69。
而bufferSize默认是取Response的缓冲区,大小大概是8000多。
那么checkContentLength永远都不会触发 doOnResponseCommitted(), 那么就无法正常写出cookie。
而如果把JSON串中的中文去掉或者改为英文,发现一切都正常了!!!
这应该算是spring-session的一个缺陷吧?