1. 程式人生 > >shiro框架之七------快取

shiro框架之七------快取

官網:https://shiro.apache.org/

一. 概述

Shiro作為一個開源的許可權框架,其元件化的設計思想使得開發者可以根據具體業務場景靈活地實現許可權管理方案,許可權粒度的控制非常方便。
首先,我們來看看Shiro框架的架構圖:

從上圖我們可以很清晰地看到,CacheManager也是Shiro架構中的主要元件之一,Shiro正是通過CacheManager元件實現許可權資料快取。
當權限資訊存放在資料庫中時,對於每次前端的訪問請求都需要進行一次資料庫查詢。特別是在大量使用shiro的jsp標籤的場景下,對應前端的一個頁面訪問請求會同時出現很多的許可權查詢操作,這對於許可權資訊變化不是很頻繁的場景,每次前端頁面訪問都進行大量的許可權資料庫查詢是非常不經濟的。因此,非常有必要對許可權資料使用快取方案。


關於shiro許可權資料的快取方式,可以分為2類:其一,將許可權資料快取到集中式儲存中介軟體中,比如redis或者memcached;其二,將許可權資料快取到本地。使用集中式快取方案,頁面的每次訪問都會向快取發起一次網路請求,如果大量使用了shiro的jsp標籤,那麼對應一個頁面訪問將會出現N個到集中快取的網路請求,會給集中快取元件帶來一定的瞬時請求壓力。另外,每個標籤都需要經過一個網路查詢,其實效率並不高。而採用本地快取方式均不存在這些問題。所以,針對shiro的快取方案,需要根據實際的使用場景進行權衡。如果在專案中並未使用shiro的jsp標籤庫,那麼使用集中式的快取方案也未嘗不妥;但是,如果大量使用shiro的jsp標籤庫,那麼採用本地快取才是最佳選擇。

二. 如何在shiro中使用快取

根據Shiro官方的說法,雖然快取在許可權框架中非常重要,但是如果實現一套完整的快取機制會使得shiro偏離了核心的功能(認證和授權)。因此,Shiro只提供了一個可以支援具體快取實現(如:Hazelcast, Ehcache, OSCache, Terracotta, Coherence, GigaSpaces, JBossCache等)的抽象API介面,這樣就允許Shiro使用者根據自己的需求靈活地選擇具體的CacheManager。當然,其實Shiro也自帶了一個本地記憶體CacheManager:org.apache.shiro.cache.MemoryConstrainedCacheManager。


其實,從Shiro快取元件類圖可以看到,Shiro提供的快取抽象API介面正是:org.apache.shiro.cache.CacheManager。
那麼,我們應該如何配置和使用CacheManager呢?如下我們以使用Shiro提供的MemoryConstrainedCacheManager元件為例進行說明。
我們知道,SecurityManager是Shiro的核心控制器,我們來看一下其類圖:

org.apache.shiro.mgt.CachingSecurityManager是Shiro中SecurityManager介面的基礎抽象類,我們來看一下其原始碼結構:

從圖中我們看到,在CachingSecurityManager中存在一個CacheManager型別的成員變數。
另外,介面org.apache.shiro.realm.Realm定義了許可權資料的儲存方式,我們看一下其類圖:

顯然,org.apache.shiro.realm.CachingRealm是Shiro中Realm介面的基礎實現類,我們同樣來看一下其原始碼結構:

同樣,在CachingRealm也存在一個CacheManager型別的成員變數。
從以上分析我們知道:Shiro支援在2個地方定義快取管理器,既可以在SecurityManager中定義,也可以在Realm中定義,任選其一即可。
通常我們都會自定義Realm實現,例如將許可權資料存放在資料庫中,那麼在Realm實現中定義快取管理器再合適不過了。
舉個例子,我們擴充套件了org.apache.shiro.realm.jdbc.JdbcRealm,在其中定義一個快取元件。

<!-- Define the Shiro Realm implementation you want to use to connect to your back-end -->
<!-- security datasource: -->
<bean id="myRealm" class="org.chench.test.shiro.spring.dao.ShiroCacheJdbcRealm">
    <property name="dataSource" ref="dataSource"/>
    <property name="permissionsLookupEnabled" value="true"/>
    <property name="cacheManager" ref="cacheManager" />
</bean>

<bean id="cacheManager" class="org.apache.shiro.cache.MemoryConstrainedCacheManager" />

當然,同樣可以在SecurityManager中定義快取元件:

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <!-- Single realm app.  If you have multiple realms, use the 'realms' property instead. -->
    <property name="realm" ref="myRealm" />
    <property name="cacheManager" ref="cacheManager" />
</bean>
<bean id="cacheManager" class="org.apache.shiro.cache.MemoryConstrainedCacheManager" />

那麼,我們不禁要問了:
第一:為什麼Shiro要設計成既可以在Realm,也可以在SecurityManager中設定快取管理器呢?
第二:分別在Realm和SecurityManager定義的快取管理器,他們有什麼區別或聯絡嗎?
下面,我們追蹤一下org.apache.shiro.mgt.RealmSecurityManage的原始碼實現:

protected void applyCacheManagerToRealms() {
    CacheManager cacheManager = getCacheManager();
    Collection<Realm> realms = getRealms();
    if (cacheManager != null && realms != null && !realms.isEmpty()) {
        for (Realm realm : realms) {
            if (realm instanceof CacheManagerAware) {
                ((CacheManagerAware) realm).setCacheManager(cacheManager);
            }
        }
    }
}

這下終於真相大白了吧!其實在SecurityManager中設定的CacheManager組中都會給Realm使用,即:真正使用CacheManager的元件是Realm。

三. 快取方案

1. 集中式快取

我們在前面分析了,使用集中式快取方案只適用於那些沒有使用shiro的jsp標籤的場景,比如:前後端完全分離的專案。目前比較流行的集中式快取元件有:Redis,Memcache等,我們可以藉助於這樣的集中式快取實現shiro的快取方案。
雖然使用了集中式快取元件,但是不必要直接把許可權資料本身存放到集中式快取中,而是通過在集中式快取中存放快取標誌即可。這樣可以避免直接從集中式快取中取許可權資料,當權限資料比較大時,大量許可權資料查詢所佔用的頻寬也是比較可觀的。

  • 基於Redis的集中式快取方案:https://github.com/alexxiyang/shiro-redis
  • 基於Memcached的集中式快取方案:https://github.com/mythfish/shiro-memcached
  • 基於Ehcache叢集模式的存放方案:http://www.ehcache.org/

2. 本地快取

本地快取的實現有幾種方式:(1)直接存放到JVM堆記憶體(2)使用NIO存放在堆外記憶體,自定義實現或者藉助於第三方快取元件。
不論是採用集中式快取還是使用本地快取,shiro的許可權資料本身都是直接存放在本地的,不同的是快取標誌的存放位置。採用本地快取方案是,我們將快取標誌也存放在本地,這樣就避免了查詢快取標誌的網路請求,能更進一步提升快取效率。

四. 快取更新

不論是集中式快取還是本地快取方案,我們都需要考慮這樣一個問題:如果使用了shiro框架的服務端進行了多例項部署,首先需要對session進行同步,因為shiro的認證資訊是存放在session中的;其次,當前端操作在某個例項上修改了許可權時,需要通知後端服務的多個例項重新獲取最新的許可權資料。那麼有哪些方案可以實現通知到後端服務的多個例項呢?

1. 組播通知

所謂組播通知即:當前端操作在後端服務的某個例項上修改了許可權時,就採用組播訊息的方式通知其他服務例項節點,把當前快取的許可權資料失效,重新從資料庫中取最新的許可權資料進行快取。雖然組播通知非常高效,而且實現也很簡單。但是,組播訊息通過UDP傳送,而UDP本身存在不可靠性。也就是說,如果在某個時刻發生某個修改了許可權的後端服務例項傳送給其他節點的組播訊息丟失而導致其他節點未收到對快取失效的通知時,將可能會導致系統的許可權管理混亂,甚至導致系統不可用,並且不好排查具體是什麼原因導致組播訊息丟失,對於系統可用性的修復帶來很大的不便利。因此,這種方式僅僅是作為一種參考實現,不在實際場景使用。
當然,組播方式有它使用的場景,但是在這裡確實不適用。

2. zk通知

zookeeper最核心的功能就是統一配置,同時還可以用來實現服務註冊與發現,在這裡使用的zookeeper特性是:watcher機制。當某個節點的狀態發生改變時,監控該節點狀態的元件將會收到通知。利用這個特點,我們可以將shiro的快取標誌通過zookeeper及時通知的方式快取在本地。當在某個後端服務節點上修改了許可權時,同時修改zookeeper節點的狀態,這樣其他服務節點也能及時收到通知,從而可以更新自己本地的快取標誌。使用zookeeper方案的好處是:即便zookeeper節點故障了,也不會導致系統不可用,最多就是不能使用快取資料而是每次都直接查詢資料庫。當zookeeper節點出現故障時後端的應用服務節點可以收到通知,更新快取標誌,並且可以發出通知。這樣,我們也可以及時發現快取方案不可用了,需要進行修復。當然,這樣做的壞處就是引入了新的節點,增加了管理的複雜性。

總之,使用zk方式來控制shiro的本地快取更新比較靈活,即便是隻有一個zk例項,也不會因為其單點故障導致程式不可用。而且,當zk故障恢復之後能夠使得web應用的本地快取更新機制恢復正常。

3. 具體實現

不論是組播通知還是zk通知,其目的都是為了解決快取更新問題。那麼,具體到程式碼實現應該怎麼做呢?
舉個例子,如果我們將許可權資料存放在MySQL中,且自定義了JDBC Realm,那麼可以在獲取快取資訊時根據條件直接清空快取即可。每次清空快取之後,Shiro會重新從資料庫中查詢最新的許可權資料進行快取。快取更新使用zk方式實現,千言萬語都不如來一段程式碼示例:

/**
 * 擴充套件使用了快取元件的JDBC Realm
 * @desc org.chench.test.shiro.spring.dao.ShiroCacheJdbcRealm
 * @date 2017年12月14日
 */
public class ShiroCacheJdbcRealm extends JdbcRealm {
    private static final Logger logger = LoggerFactory.getLogger(ShiroCacheJdbcRealm.class);

    @Override
    public Cache<Object, AuthorizationInfo> getAuthorizationCache() {
        Cache<Object, AuthorizationInfo> cache = super.getAuthorizationCache();
        if(cache == null) {
            return cache;
        }
        if(!Constants.isConnected() || Constants.isRefresh()) {
            if(logger.isWarnEnabled()) {
                logger.warn("clear shiro cache");
            }
            cache.clear();
        }
        return cache;
    }
}
/**
 * 在應用上下文監聽器中監聽zk事件,從而實現shiro快取更新通知.
 * @desc org.chench.test.shiro.spring.listener.ShiroCacheListener
 * @date 2017年12月13日
 */
public class ShiroCacheListener implements ServletContextListener, Watcher, StatCallback {
    private Logger logger = LoggerFactory.getLogger(ShiroCacheListener.class);
    private ZooKeeper zk = null;
    
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        logger.info("shiro cache listener context initialized");
        init();
    }

    @Override
    public void contextDestroyed(ServletContextEvent sce) {
        logger.info("shiro cache listener context destroyed");
        release();
    }
    
    private void init() {
        try {
            zk = new ZooKeeper(Constants.ZK_SERVERS, Constants.ZK_SESSION_TIMEOUT, this);
            Stat stat = zk.exists(Constants.ZK_ZNODE_SHIRO_CACHE, false);
            if(stat != null) {
                zk.exists(Constants.ZK_ZNODE_SHIRO_CACHE, true, this, null);
                return;
            }
            
            byte[] data = String.valueOf(Calendar.getInstance().getTime().getTime()).getBytes();
            zk.create(Constants.ZK_ZNODE_SHIRO_CACHE, data, Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
        } catch (Exception e) {
            e.printStackTrace();
            Constants.setRefresh(true);
        }
    }

    private void release() {
        try {
            if(zk != null) {
                zk.close();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            zk = null;
        }
    }
    
    @Override
    public void process(WatchedEvent event) {
        String path = event.getPath();
        logger.info("watcher process path: " + path + " event type: " + event.getType());
        if(Event.EventType.None == event.getType()) {
            switch (event.getState()) {
            case SyncConnected:
                logger.info("watcher process SyncConnected");
                Constants.setConnected(true);
                break;
            case Disconnected:
            case Expired:
                logger.info("watcher process {}", event.getState());
                Constants.setConnected(false);
                Constants.setRefresh(true);
                break;
            default:
                break;
            }
        }else if(Event.EventType.NodeCreated == event.getType()) {
            if(Constants.ZK_ZNODE_SHIRO_CACHE.equals(path)) {
                zk.exists(Constants.ZK_ZNODE_SHIRO_CACHE, true, this, null);
            }
        }else if(Event.EventType.NodeDataChanged == event.getType()){
            if(Constants.ZK_ZNODE_SHIRO_CACHE.equals(path)) {
                zk.exists(Constants.ZK_ZNODE_SHIRO_CACHE, true, this, null);
                Constants.setRefresh(true);
            }
        }else {
            logger.info("do nothing");
        }
    }

    // 讀取znode資料
    @Override
    public void processResult(int rc, String path, Object ctx, Stat stat) {
        logger.info("rc: {}, path:{}, ctx: {}, stat: {}", new Object[] {rc, path, ctx, stat});
        
        switch (rc) {
        case Code.Ok:
            logger.info("statcallback proess result Ok");
            break;
        case Code.NoNode:
            logger.info("statcallback proess result NoNode");
            break;
        case Code.ConnectionLoss:
            logger.info("statcallback proess result ConnectionLoss");
            break;
        case Code.SessionExpired:
            logger.info("statcallback proess result SessionExpired");
            break;
        case Code.OperationTimeout:
            logger.info("statcallback proess result OperationTimeout");
            break;
        default:
            zk.exists(Constants.ZK_ZNODE_SHIRO_CACHE, true, this, null);
            break;
        }
        
        try {
            byte[] bytes = zk.getData(Constants.ZK_ZNODE_SHIRO_CACHE, false, null);
            long timestamp = Long.valueOf(new String(bytes, 0, bytes.length));
            SimpleDateFormat format =new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
            logger.info("修改時間: " + format.format(new Date(timestamp)));
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

【參考】
https://shiro.apache.org/caching.html