Shiro權限管理框架(四):深入分析Shiro中的Session管理

  • 2019 年 10 月 31 日
  • 筆記

其實關於Shiro的一些學習筆記很早就該寫了,因為懶癌和拖延症晚期一直沒有落實,直到今天公司的一個項目碰到了在集群環境的單點登錄頻繁掉線的問題,為了解決這個問題,Shiro相關的文檔和教程沒少翻。最後問題解決了,但我覺得我也是時候來做一波Shiro學習筆記了。

本篇是Shiro系列第四篇,Shiro中的過濾器初始化流程和實現原理。Shiro基於URL的權限控制是通過Filter實現的,本篇從我們注入的ShiroFilterFactoryBean開始入手,翻看源碼追尋Shiro中的過濾器的實現原理。

首發地址:https://www.guitu18.com/post/2019/08/08/45.html

Session

SessionManager

我們在配置Shiro時配置了一個DefaultWebSecurityManager,先來看下

DefaultWebSecurityManager

    public DefaultWebSecurityManager() {          super();          ((DefaultSubjectDAO) this.subjectDAO).setSessionStorageEvaluator(new DefaultWebSessionStorageEvaluator());          this.sessionMode = HTTP_SESSION_MODE;          setSubjectFactory(new DefaultWebSubjectFactory());          setRememberMeManager(new CookieRememberMeManager());          setSessionManager(new ServletContainerSessionManager());      }

在它的構造方法中注入了一個ServletContainerSessionManager

public class ServletContainerSessionManager implements WebSessionManager {      public Session getSession(SessionKey key) throws SessionException {          if (!WebUtils.isHttp(key)) {              String msg = "SessionKey must be an HTTP compatible implementation.";              throw new IllegalArgumentException(msg);          }          HttpServletRequest request = WebUtils.getHttpRequest(key);          Session session = null;          HttpSession httpSession = request.getSession(false);          if (httpSession != null) {              session = createSession(httpSession, request.getRemoteHost());          }          return session;      }        private String getHost(SessionContext context) {          String host = context.getHost();          if (host == null) {              ServletRequest request = WebUtils.getRequest(context);              if (request != null) {                  host = request.getRemoteHost();              }          }          return host;      }        protected Session createSession(SessionContext sessionContext) throws AuthorizationException {          if (!WebUtils.isHttp(sessionContext)) {              String msg = "SessionContext must be an HTTP compatible implementation.";              throw new IllegalArgumentException(msg);          }          HttpServletRequest request = WebUtils.getHttpRequest(sessionContext);          HttpSession httpSession = request.getSession();          String host = getHost(sessionContext);          return createSession(httpSession, host);      }        protected Session createSession(HttpSession httpSession, String host) {          return new HttpServletSession(httpSession, host);      }  }

ServletContainerSessionManager本身並不管理會話,它最終操作的還是HttpSession,所以只能在Servlet容器中起作用,它不能支持除使用HTTP協議的之外的任何會話。

所以一般我們配置Shiro都會配置一個DefaultWebSessionManager,它繼承了DefaultSessionManager,看看DefaultSessionManager的構造方法:

public DefaultSessionManager() {      this.deleteInvalidSessions = true;      this.sessionFactory = new SimpleSessionFactory();      this.sessionDAO = new MemorySessionDAO();  }

這裡的sessionDAO初始化了一個MemorySessionDAO,它其實就是一個Map,在內存中通過鍵值對管理Session。

public MemorySessionDAO() {      this.sessions = new ConcurrentHashMap<Serializable, Session>();  }

HttpServletSession

public class HttpServletSession implements Session {      public HttpServletSession(HttpSession httpSession, String host) {          if (httpSession == null) {              String msg = "HttpSession constructor argument cannot be null.";              throw new IllegalArgumentException(msg);          }          if (httpSession instanceof ShiroHttpSession) {              String msg = "HttpSession constructor argument cannot be an instance of ShiroHttpSession.  This " +                      "is enforced to prevent circular dependencies and infinite loops.";              throw new IllegalArgumentException(msg);          }          this.httpSession = httpSession;          if (StringUtils.hasText(host)) {              setHost(host);          }      }      protected void setHost(String host) {          setAttribute(HOST_SESSION_KEY, host);      }      public void setAttribute(Object key, Object value) throws InvalidSessionException {          try {              httpSession.setAttribute(assertString(key), value);          } catch (Exception e) {              throw new InvalidSessionException(e);          }      }  }

Shiro的HttpServletSession只是對javax.servlet.http.HttpSession進行了簡單的封裝,所以在Web應用中對Session的相關操作最終都是對javax.servlet.http.HttpSession進行的,比如上面代碼中的setHost()是將內容以鍵值對的形式保存在httpSession中。

先來看下這張圖:

先了解上面這幾個類的關係和作用,然後我們想管理Shiro中的一些數據就非常方便了。

SessionDao是Session管理的頂層接口,定義了Session的增刪改查相關方法。

public interface SessionDAO {      Serializable create(Session session);      Session readSession(Serializable sessionId) throws UnknownSessionException;      void update(Session session) throws UnknownSessionException;      void delete(Session session);      Collection<Session> getActiveSessions();  }

AbstractSessionDao是一個抽象類,在它的構造方法中定義了JavaUuidSessionIdGenerator作為SessionIdGenerator用於生成SessionId。它雖然實現了create()和readSession()兩個方法,但具體的流程調用的是它的兩個抽象方法doCreate()和doReadSession(),需要它的子類去幹活。

public abstract class AbstractSessionDAO implements SessionDAO {      private SessionIdGenerator sessionIdGenerator;      public AbstractSessionDAO() {          this.sessionIdGenerator = new JavaUuidSessionIdGenerator();      }        public Serializable create(Session session) {          Serializable sessionId = doCreate(session);          verifySessionId(sessionId);          return sessionId;      }      protected abstract Serializable doCreate(Session session);        public Session readSession(Serializable sessionId) throws UnknownSessionException {          Session s = doReadSession(sessionId);          if (s == null) {              throw new UnknownSessionException("There is no session with id [" + sessionId + "]");          }          return s;      }      protected abstract Session doReadSession(Serializable sessionId);  }

看上面那張類圖AbstractSessionDao的子類有三個,查看源碼發現CachingSessionDAO是一個抽象類,它並沒有實現這兩個方法。在它的子類EnterpriseCacheSessionDAO中實現了doCreate()和doReadSession(),但doReadSession()是一個空實現直接返回null。

    public EnterpriseCacheSessionDAO() {          setCacheManager(new AbstractCacheManager() {              @Override              protected Cache<Serializable, Session> createCache(String name) throws CacheException {                  return new MapCache<Serializable, Session>(name, new ConcurrentHashMap<Serializable, Session>());              }          });      }

EnterpriseCacheSessionDAO依賴於它的父級CachingSessionDAO,在他的構造方法中向父類注入了一個AbstractCacheManager的匿名實現,它是一個基於內存的SessionDao,它所創建的MapCache就是一個Map。

我們在Shiro配置類里通過 sessionManager.setSessionDAO(new EnterpriseCacheSessionDAO()); 來使用它。然後在CachingSessionDAO.getCachedSession() 打個斷點測試一下,可以看到cache就是一個ConcurrentHashMap,在內存中以Key-Value的形式保存着JSESSIONID和Session的映射關係。

再來看AbstractSessionDao的第三個實現MemorySessionDAO,它就是一個基於內存的SessionDao,簡單直接,構造方法直接new了一個ConcurrentHashMap。

    public MemorySessionDAO() {          this.sessions = new ConcurrentHashMap<Serializable, Session>();      }

那麼它和EnterpriseCacheSessionDAO有啥區別,其實EnterpriseCacheSessionDAO只是CachingSessionDAO的一個默認實現,在CachingSessionDAO中cacheManager是沒有默認值的,在EnterpriseCacheSessionDAO的構造方法將其初始化為一個ConcurrentHashMap。

如果我們直接用EnterpriseCacheSessionDAO其實和MemorySessionDAO其實沒有什麼區別,都是基於Map的內存型SessionDao。但是CachingSessionDAO的目的是為了方便擴展的,用戶可以繼承CachingSessionDAO並注入自己的Cache實現,比如以Redis緩存Session的RedisCache。

其實在業務上如果需要Redis來管理Session,那麼直接繼承AbstractSessionDao更好,有Redis支撐中間的Cache就是多餘的,這樣還可以做分佈式或集群環境的Session共享(分佈式或集群環境如果中間還有一層Cache那麼還要考慮同步問題)。

回到開篇提到的我們公司項目集群環境下單點登錄頻繁掉線的問題,其實就是中間那層Cache造成的。我們的業務代碼中RedisSessionDao是繼承自EnterpriseCacheSessionDAO的,這樣一來那在Redis之上還有一個基於內存的Cache層。此時用戶的Session如果發生變更,雖然Redis中的Session是同步的,Cache層沒有同步,導致的現象就是用戶在一台服務器的Session是有效的,另一台服務器Cache中的Session還是舊的,然後用戶就被迫下線了。