根据ID来管理分布式session - 新老界面session不一致导致强制登出问题的修复
背景
由于历史原因,原先的界面是用vaadin框架来实现。但是这个框架不适合互联网的分布式系统,正在逐步用目前主流的前端框架重写各个模块,把旧的vaadin页面替换掉。在替换过程中,新老界面并存。
vaadin界面的servlet,每次都会判断请求中的session id值,如果在服务器中找不到对应这个id的session,就会重新生成一个。由于session id是在新界面登录的时候生成的,当点击链接从新界面跳转到vaadin界面的时候,vaadin服务会发现没有这个session id,就会重新生成一个新的session。换句话说,新老界面会有各自的session id。当然,分布式系统本来就有这个问题,可以采用分布式session的来解决。
原先开发人员的解决方案是:
- 把session信息存入redis缓存,session id作为key
- 每次跳转到vaadin界面后,用新生成的session id替换掉旧的session id,同时在redis里面把session信息从旧的key,复制到新的key这边。
但是这种解决方案存在一个问题,主要是由上面解决方案的第2点引起的。由于每次从新界面跳转到vaadin界面都会生成新的session id,如果打开两个浏览器页面,分别跳转到新的界面,那么这个就会导致第一个跳转的那个浏览器页面中的session id被覆盖。vaadin框架是有状态的,它在客户端与服务器端保持一个长连接,并检测session id的有效性。session id的变化,导致长连接的登录信息失效,被弹回到登录界面。从用户体验上来讲,就是被强制登出。
从另外一个角度说,原先开发人员的解决方案是不合理的,它也不是一种标准的分布式session的解决方案。
如何解决
比较合适的解决方案是,vaadin界面不能自己生成session id,而是要复用在新界面登录之后所生成的session id。当vaadin界面有请求的时候,根据请求中的session id查询服务器上是否有对应的session,如果有则返回对应的session;如果没有,则新建一个以该session id为主键的session。换句话说,新老界面都应该要使用相同session id,一旦登录之后,在当前用户这次登录的有效生命周期内,session id保持不变。这样就不会存在上面说的,由于session id的变化而导致被强制登出的问题。
具体实现的关键是用到HttpServletRequesWrapper类,它能够快速提供HttpServletRequest的自定义实现。
|----------------------| | (I) ServletRequest | |----------------------| | | ------------------------------------------- | | | | |--------------------------| |-----------------------------| | (I) HttpServletRequest | | (C) ServletRequestWrapper | |--------------------------| |-----------------------------| | | | | ------------------------------------------- | | |---------------------------------| | (C) HttpServletRequestWrapper | |---------------------------------|
如下的代码是从 Tomcat 抽取出来的,展现了HttpServletRequesWrapper类是如何运行的。
public class HttpServletRequestWrapper extends ServletRequestWrapper
implements HttpServletRequest {
public HttpServletRequestWrapper(HttpServletRequest request) {
super(request);
}
private HttpServletRequest _getHttpServletRequest() {
return (HttpServletRequest) super.getRequest();
}
public HttpSession getSession(boolean create) {
return this._getHttpServletRequest().getSession(create);
}
public HttpSession getSession() {
return this._getHttpServletRequest().getSession();
}
// 为了保证可读性,其他的方法删减掉了
}
我们所需要做的事情,就是重写getSession这个方法,获取Cookie中的JESSIONID,来获取或者新建session。这里由于vaadin框架里面的一些实现,把session所保存的状态放入外部存储不是一个合适的选择,所以就新建了个MySessionContext用于存放,当然还需要额外的代码来清除MySessionContext中过期的session信息。
public class MyHttpServletRequestWrapper extends HttpServletRequestWrapper {
public MyHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public HttpSession getSession(boolean create) {
...
// 获取请求中的session id,用于获取已有的session或者新建session
String requestedSessionId = getRequestedSessionId();
if(requestedSessionId != null) {
// 根据指定的session id,获取已有的session
HttpSession session = MySessionContext.getSession(requestedSessionId);
if (session != null) {
return session;
}
}
if(!create) {
return null;
}
// 新建session,用指定的session id作为key保存
return MySessionContext.addSession(requestedSessionId, createNewSession());
}
@Override
public HttpSession getSession() {
return getSession(true);
}
@Override
public String getRequestedSessionId() {
// 读取Cookie中JSESSIONID的值,为了方便阅读,省略具体实现
...
}
// 新建session
private HttpSession createNewSession() {
return (HttpServletRequest)this.getRequest().getSession(true);
}
}
通过filter来使用包装类
public class MyFilter implements Filter {
/*
* 这个方法创建了我们上文所述的封装请求对象, 然后调用其余的 filter 链。
* 这里,当这个filter后面的应用代码执行时,如果要获得session的话,将
* 会使用我们上面所写的方式来获得
*/
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
MyHttpServletRequestWrapper wrappedRequest =
new MyHttpServletRequestWrapper(request);
filterChain.doFilter(wrappedRequest, strategyResponse);
}
}
在web.xml中启用filter
<filter>
<filter-name>MyFilter</filter-name>
<filter-class>net.zengxi.filter.MyFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>MyFilter</filter-name>
<url-pattern>/module/*</url-pattern>
</filter-mapping>