根据ID来管理分布式session - 新老界面session不一致导致强制登出问题的修复

根据ID来管理分布式session - 新老界面session不一致导致强制登出问题的修复

January 26, 2020

背景

由于历史原因,原先的界面是用vaadin框架来实现。但是这个框架不适合互联网的分布式系统,正在逐步用目前主流的前端框架重写各个模块,把旧的vaadin页面替换掉。在替换过程中,新老界面并存。

vaadin界面的servlet,每次都会判断请求中的session id值,如果在服务器中找不到对应这个id的session,就会重新生成一个。由于session id是在新界面登录的时候生成的,当点击链接从新界面跳转到vaadin界面的时候,vaadin服务会发现没有这个session id,就会重新生成一个新的session。换句话说,新老界面会有各自的session id。当然,分布式系统本来就有这个问题,可以采用分布式session的来解决。

原先开发人员的解决方案是:

  1. 把session信息存入redis缓存,session id作为key
  2. 每次跳转到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>

参考资料

最后更新于