Shiro許可權管理框架(二):Shiro結合Redis實現分散式環境下的Session共享

夜月歸途發表於2019-07-29

首發地址:https://www.guitu18.com/post/2019/07/28/44.html


本篇是Shiro系列第二篇,使用Shiro基於Redis實現分散式環境下的Session共享。在講Session共享之前先說一下為什麼要做Session共享。

為什麼要做Session共享

什麼是Session

我們都知道HTTP協議(1.1)是無狀態的,所以伺服器在需要識別使用者訪問的時候,就要做相應的記錄用於跟蹤使用者操作,這個實現機制就是Session。當一個使用者第一次訪問伺服器的時候,伺服器就會為使用者建立一個Session,每個Session都有一個唯一的SessionId(應用級別)用於標識使用者。

Session通常不會單獨出現,因為請求是無狀態的,那麼我們必須讓使用者在下次請求時帶上伺服器為其生成的Session的ID,通常的做法時使用Cookie實現(當然你要非要在請求引數中帶上SessionId那也不是不行)。請求返回時會向瀏覽器的Cookie中寫入SessionID,通常使用的鍵是JSESSIONID,這樣下次使用者再請求這臺伺服器時,伺服器就能從Cookie中取出SessionId識別出該次請求的使用者是誰。

舉個栗子:

img

左邊紅框部分是Cookie列表,當前伺服器是:localhost:28080。右邊紅框部分從左到右依次是Cookie的鍵、值、主機、路徑和過期時間。路徑為/時表示全站有效,最後一個過期時間未設定的話是預設值為Session,表示瀏覽器關閉時該Cookie失效。我們也可以為Cookie指定過期時間,以做到會話保持。

什麼是Session共享

通過Session和Cookie,我們使得無狀態的HTTP協議間接的變成了有狀態的了,可以實現保持登入,儲存使用者資訊,購物車等等功能。但是隨著服務訪問人數的增多,單臺伺服器已經不足以應付所有的請求了,必須部署叢集環境。但是隨著叢集環境的出現,追蹤使用者狀態的問題又開始出現問題,之前使用者在A伺服器登入,A伺服器儲存了使用者資訊,但是下一次請求傳送到B伺服器去了,這時候B伺服器是不知道使用者在A伺服器登入的事情的,它雖然也能拿到使用者請求Cookie中的SessionId,但是在B服務根據這個SessionId找不到對應的Session,B伺服器就會認為使用者沒有登入,需要使用者重新登入,這對使用者來說是沒辦法接受的。

這時候常見的有兩種方式解決這個問題,第一種是讓這個使用者所有的請求都傳送到A伺服器,比如根據IP地址做一些列演算法將所有使用者分配到不同的伺服器上去,讓每個使用者只訪問其中的一臺伺服器。這種做法可行,但是後續也會產生其它問題,更好的做法是第二種,將所有的伺服器上的Session都做成共享的,A服務能拿到B伺服器上的所有Session,同理B伺服器也能獲取A伺服器所有的Session,這樣上面的問題就不存在了。


Shiro結合Redis實現Session共享

上一篇已經通過Shiro實現了使用者登入和許可權管理,Shiro的登入也是基於Session的,預設情況下Session是儲存在記憶體中。既然要做Session共享,那麼肯定是將Session抽取出來,放到一個多個伺服器都能訪問到的地方。

在叢集環境下,我們僅僅需要繼承AbstractSessionDAO,實現一下Session的增刪改查等幾個方法就可以很方便的實現Session共享,Shiro已經將完整的流程都做好了。這裡涉及到的設計模式是模板方法模式,我們僅需要參與部分業務就可以完善整個流程了,當然我們不參與這部分流程的話,Shiro也有預設的實現方式,那就是將Session管理在當前應用的記憶體中。

具體的Session管理(共享)怎麼實現由我們自己決定,可以存放在資料庫,也可以通過網路傳輸,甚至可以通過IO流寫入檔案都行,但就效能來講,我們一般都將Session放入Redis中。Redis大法好!YES~

自定義RedisSessionDAO

理解了原理之後就很容易辦事了,繼承AbstractSessionDAO後實現Session增刪改查的幾個方法,然後再分散式系統中所有的專案再需要儲存或獲取Session時都會走Redis操作,這樣就做到了叢集環境的Session共享了。程式碼非常簡單:

@Component
public class RedisSessionDao extends AbstractSessionDAO {

    @Value("${session.redis.expireTime}")
    private long expireTime;

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    protected Serializable doCreate(Session session) {
        Serializable sessionId = this.generateSessionId(session);
        this.assignSessionId(session, sessionId);
        redisTemplate.opsForValue().set(session.getId(), session, expireTime, TimeUnit.SECONDS);
        return sessionId;
    }

    @Override
    protected Session doReadSession(Serializable sessionId) {
        return sessionId == null ? null : (Session) redisTemplate.opsForValue().get(sessionId);
    }

    @Override
    public void update(Session session) throws UnknownSessionException {
        if (session != null && session.getId() != null) {
            session.setTimeout(expireTime * 1000);
            redisTemplate.opsForValue().set(session.getId(), session, expireTime, TimeUnit.SECONDS);
        }
    }

    @Override
    public void delete(Session session) {
        if (session != null && session.getId() != null) {
            redisTemplate.opsForValue().getOperations().delete(session.getId());
        }
    }

    @Override
    public Collection<Session> getActiveSessions() {
        return redisTemplate.keys("*");
    }

}

配置檔案中新增上面用到的配置

###redis連線配置
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=foobared
### Session過期時間(秒)
session.redis.expireTime=3600

注入RedisSessionDao

上面只是我們自己實現的管理Session的方式,現在需要將其注入SessionManager中,並設定過期時間等相關引數。

    @Bean
    public DefaultWebSessionManager defaultWebSessionManager(RedisSessionDao redisSessionDao) {
        DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
        sessionManager.setGlobalSessionTimeout(expireTime * 1000);
        sessionManager.setDeleteInvalidSessions(true);
        sessionManager.setSessionDAO(redisSessionDao);
        sessionManager.setSessionValidationSchedulerEnabled(true);
        sessionManager.setDeleteInvalidSessions(true);
        /**
         * 修改Cookie中的SessionId的key,預設為JSESSIONID,自定義名稱
         */
        sessionManager.setSessionIdCookie(new SimpleCookie("JSESSIONID"));
        return sessionManager;
    }

再將SessionManager注入Shiro的安全管理器SecurityManager中,前面說過,我們圍繞安全相關的所有操作,都需要與SecurityManager打交道,這位才是Shiro中真正的老大哥。

    @Bean
    public SecurityManager securityManager(UserAuthorizingRealm userRealm, RedisSessionDao redisSessionDao) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(userRealm);
        // 取消Cookie中的RememberMe引數
        securityManager.setRememberMeManager(null);
        securityManager.setSessionManager(defaultWebSessionManager(redisSessionDao));
        return securityManager;
    }

OK,至此基於Redis實現的Session共享就完成了,是不是簡單得不可思議。

注意:基於網路傳輸的物件請實現Serializable序列化介面,比如User類。

測試

將這套程式碼用不同的埠跑兩套服務(理論上跑多少套都可以只要你的配置夠用),訪問兩臺伺服器獲取使用者資訊的介面,未登入狀態毫無疑問都會跳到登入頁去:

img

在任意一臺伺服器上呼叫登入介面登入:

img

登入成功後再次分別訪問兩臺伺服器獲取使用者資訊的介面:

img

如此,分散式環境Session共享完美實現。最後繼續放上專案程式碼,程式碼還是很早之前的,部分程式碼為了配合此篇筆記經過修改整理後上傳。

Gitee:https://gitee.com/guitu18/ShiroDemo

GitHub:https://github.com/guitu18/ShiroDemo


本篇結束,簡直不要太簡單是不是,其實這主要是因為大部分工作Shiro都幫我們做了,細節的東西都被Shiro隱藏起來,我們僅僅需要新增一些簡單的配置就可以實現強大的功能,這就是框架的好處。

但是作為一個程式設計師,僅僅呼叫一個方法或者新增一個註解就實現了一套很強大的功能,而我們卻看不到一個if判斷和for迴圈的時候心裡應該是非常不踏實的。我們不僅要學會使用框架,更要去深入理解框架,至少要知道為什麼我們就加了一個註解框架就能幫我們實現一大堆功能,只有這樣才能讓我們感到腳踏實地。下一篇,深入Shiro原始碼看看,可能需要醞釀一下想想筆記怎麼寫。

相關文章