細說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的緩存方案。
雖然使用了集中式緩存組件,但是不必要直接把權限數據本身存放到集中式緩存中,而是通過在集中式緩存中存放緩存標志即可。這樣可以避免直接從集中式緩存中取權限數據,當權限數據比較大時,大量權限數據查詢所占用的帶寬也是比較可觀的。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM