企業級自定義表單引擎解決方案(十)–快取設計2

  新年伊始,萬物皆生機,然冠未去,美帝相向,於華夏之子,吾輩當自強。

  這篇文章接上一篇文章,主要介紹快取的程式碼實現

後端本地快取

  之前介紹的將自定義表單數據全部存儲到應用程式記憶體中,任何自定義表單數據更新之後,都刷新記憶體快取,分散式部署涉及到快取同步刷新問題。

  • 全局本地快取容器設計
  1. 用執行緒安全的字典ConcurrentDictionary<string, object> CacheDict,存儲每一個數據對象集合,比如視圖集合、表單集合等,每一次數據變更都清除具體的一個字典項數據
  2. 絕大多數時間都是讀取快取內容,因此這裡上的讀寫鎖,讀寫每一項快取時,都上自己的讀鎖,鎖的集合存儲在ConcurrentDictionary<string, ReaderWriterLock> CacheReaderWriterLockDict變數中,Key與CacheDict的Key相同。
  3. 當檢測到快取通知服務斷開時,會將本地所有快取清空,直接讀取原始資料庫,用bool IsEnabledLocalCache變數控制。
  4. 當讀取快取時,發現本地快取沒有數據,則調用具體載入數據委託方法,本地沒有數據讀取時,需要加鎖,防止快取穿透。

具體程式碼如下:

/// <summary>
    /// 本地快取容器
    /// </summary>
    public class LocalCacheContainer
    {
        private static ConcurrentDictionary<string, object> CacheDict;
        private static ConcurrentDictionary<string, ReaderWriterLock> CacheReaderWriterLockDict;

        static LocalCacheContainer()
        {
            CacheDict = new ConcurrentDictionary<string, object>();
            CacheReaderWriterLockDict = new ConcurrentDictionary<string, ReaderWriterLock>();
        }

        public static bool IsEnabledLocalCache { get; private set; } = true;

        /// <summary>
        /// 快取通知斷開時調用
        /// </summary>
        /// <param name="isEnabled">是否啟用快取</param>
        internal static void SetLocalCacheIsEnabled(bool isEnabled)
        {
            IsEnabledLocalCache = isEnabled;
            if(!isEnabled)
            {
                ClearAllCache();
            }
        }

        public static object Get(string key, Func<string, object> factory)
        {
            var readerWriterLock = GetReadWriteLock(key);
            readerWriterLock.AcquireReaderLock(5000);

            try
            {
                //return CacheDict.GetOrAdd(key, factory); // 快取穿透?
                if (CacheDict.ContainsKey(key))
                {
                    return CacheDict.GetOrAdd(key, factory);
                }
                else
                {
                    lock (string.Intern(key))
                    {
                        return CacheDict.GetOrAdd(key, factory);
                    }
                }
            }
            finally
            {
                readerWriterLock.ReleaseReaderLock();
            }
        }

        internal static void ClearCache(string key)
        {
            var readerWriterLock = GetReadWriteLock(key);
            readerWriterLock.AcquireWriterLock(5000);

            try
            {
                object objRemove;
                CacheDict.TryRemove(key, out objRemove);
            }
            finally
            {
                readerWriterLock.ReleaseReaderLock();
            }
        }

        // 清楚所有快取資訊
        private static void ClearAllCache()
        {
            CacheDict.Clear();
            CacheReaderWriterLockDict.Clear();
        }

        private static ReaderWriterLock GetReadWriteLock(string key)
        {
            return CacheReaderWriterLockDict.GetOrAdd(key, k =>
            {
                return new ReaderWriterLock();
            });
        }
    }

快取變更處理

  1. 主要分為快取變更通知與接收快取變更處理,快取變更只需要通知哪一個Key過期即可。
  2. 接收快取變更處理比較簡單,接收到快取變更之後,將記憶體容器中對應的字典項刪除即可。
  3. 快取通知定義為介面,如果是單應用部署,直接調用刪除本地快取服務即可,如果是分散式部署,也會調用刪除本地快取數據,通知發送分散式通知到其他自定義表單應用伺服器,其他自定義表單應用伺服器接收到快取變更通知時,刪除本地快取數據。
  • ReceiveCacheNotice程式碼
public static class ReceiveCacheNotice
    {
        public static void ReceiveClearCache(string key)
        {
            LocalCacheContainer.ClearCache(key);
        }

        public static void ReceiveClearCaches(List<string> keys)
        {
            foreach(var key in keys)
            {
                LocalCacheContainer.ClearCache(key);
            }
        }

        public static void SetLocalCacheIsEnabled(bool isEnabled)
        {
            LocalCacheContainer.SetLocalCacheIsEnabled(isEnabled);
        }
    }
  • ICacheSendNotice及本地通知LocalCacheSendNotice程式碼
    /// <summary>
    /// 設計時實體變更通知快取
    /// </summary>
    public interface ICacheSendNotice
    {
        /// <summary>
        /// 發送快取變更
        /// </summary>
        /// <param name="key">快取Key</param>
        void SendClearCache(string key);

        /// <summary>
        /// 發送快取多個變更
        /// </summary>
        /// <param name="key">快取Key集合</param>
        void SendClearCaches(List<string> keys);
    }

    /// <summary>
    /// 本地快取容器通知服務
    /// </summary>
    public class LocalCacheSendNotice : ICacheSendNotice
    {
        public void SendClearCache(string key)
        {
            ReceiveCacheNotice.ReceiveClearCache(key);
        }

        public void SendClearCaches(List<string> keys)
        {
            ReceiveCacheNotice.ReceiveClearCaches(keys);
        }
    }
  • 分散式快取發布訂閱Redis實現,主要是用StackExchange.Redis組件實現,程式碼沒有太多的邏輯,閱讀程式碼即可。
/// <summary>
    /// Redis快取容器通知服務
    /// </summary>
    public class RedisCacheSendNotice : ICacheSendNotice
    {
        private readonly SpriteConfig _callHttpConfig;
        private readonly IDistributedCache _distributedCache;
        private readonly ISubscriber _subscriber;

        public RedisCacheSendNotice(IDistributedCache distributedCache, IOptions<SpriteConfig> callHttpConfig)
        {
            _distributedCache = distributedCache;
            _callHttpConfig = callHttpConfig.Value;
            var spriteRedisCache = _distributedCache as SpriteRedisCache;
            spriteRedisCache.RedisDatabase.Multiplexer.ConnectionFailed += Multiplexer_ConnectionFailed;
            spriteRedisCache.RedisDatabase.Multiplexer.ConnectionRestored += Multiplexer_ConnectionRestored;
            _subscriber = spriteRedisCache.RedisDatabase.Multiplexer.GetSubscriber();

            if (_callHttpConfig.RemoteReceivePreKey != null)
            {
                foreach (var remoteReceivePreKey in _callHttpConfig.RemoteReceivePreKey)
                {
                    _subscriber.Subscribe(remoteReceivePreKey, (channel, message) =>
                    {
                        ReceiveCacheNotice.ReceiveClearCache(message);
                    });

                    _subscriber.Subscribe($"{remoteReceivePreKey}s", (channel, message) =>
                    {
                        List<string> keys = JsonConvert.DeserializeObject<List<string>>(message);
                        ReceiveCacheNotice.ReceiveClearCaches(keys);
                    });
                }
            }
        }

        private void Multiplexer_ConnectionRestored(object sender, StackExchange.Redis.ConnectionFailedEventArgs e)
        {
            ReceiveCacheNotice.SetLocalCacheIsEnabled(true);
        }

        private void Multiplexer_ConnectionFailed(object sender, StackExchange.Redis.ConnectionFailedEventArgs e)
        {
            ReceiveCacheNotice.SetLocalCacheIsEnabled(false);
        }

        public void SendClearCache(string key)
        {
            ReceiveCacheNotice.ReceiveClearCache(key);
            if (_callHttpConfig.RemoteNoticePreKey != null)
            {
                if (_callHttpConfig.RemoteNoticePreKey.Any(r => key.StartsWith($"{r}-")))
                {
                    _subscriber.Publish(key.Split('-')[0], key);
                }
            }
        }

        public void SendClearCaches(List<string> keys)
        {
            ReceiveCacheNotice.ReceiveClearCaches(keys);
            if (_callHttpConfig.RemoteNoticePreKey != null)
            {
                var groupKeyLists = keys.GroupBy(r => r.Split('-')[0]);
                foreach (var groupKeyList in groupKeyLists)
                {

                    if (_callHttpConfig.RemoteNoticePreKey.Any(r => groupKeyList.Key == r))
                    {
                        _subscriber.Publish($"{groupKeyList.Key}s", JsonConvert.SerializeObject(groupKeyList.ToList()));
                    }
                }
            }
        }
    }
  • 具體快取程式碼實現舉例(以表單為例)
public class SpriteFormLocalCache : LocalCache<SpriteFormVueDto>
    {
        public override string CacheKey => CommonConsts.SpriteFormCacheKey;

        public override Dictionary<Guid, SpriteFormVueDto> GetAllDict(string applicationCode)
        {
            if (!LocalCacheContainer.IsEnabledLocalCache) // 如果快取通知服務不可以,直接讀取資料庫
            {
                return _serviceProvider.DoDapperService(DefaultDbConfig, (unitOfWork) =>
                {
                    return GetSpriteFormVueDtos(applicationCode, unitOfWork);
                });
            }
            else
            {
	// 讀取本地快取內容,如果本地快取沒有數據,讀取資料庫數據,並寫入本地快取容器
                return (Dictionary<Guid, SpriteFormVueDto>)LocalCacheContainer.Get($"{CommonConsts.SpriteFormCachePreKey}-{applicationCode}_{CacheKey}", key =>
                {
                    return _serviceProvider.DoDapperService(DefaultDbConfig, (unitOfWork) =>
                    {
                        return GetSpriteFormVueDtos(applicationCode, unitOfWork);
                    });
                });
            }
        }
......
}
  • 前端快取主要是用IndexDb實現,前端程式碼暫時沒開源,閱讀一下即可
import Dexie from 'dexie'
import { SpriteRumtimeApi } from '@/sprite/api/spriteform'

const db = new Dexie('formDb')
db.version(1).stores({
    form: `id`
})

db.version(1).stores({
    view: `id`
})

db.version(1).stores({
    frameworkCache: `id`
})

db.version(1).stores({
    dict: `id`
})

window.spriteDb = db

db.menuFormRelationInfo = {}
const createMenuFormRelations = function (routeName, applicationCode, relationInfos) {
    if (!db.menuFormRelationInfo.hasOwnProperty(routeName)) {
        db.menuFormRelationInfo[routeName] = {}
        db.menuFormRelationInfo[routeName].applicationCode = applicationCode
        db.menuFormRelationInfo[routeName].relationInfos = relationInfos
    } else {
        relationInfos.forEach(relationInfo => {
            if (!db.menuFormRelationInfo[routeName].relationInfos.find(r => r.relationType === relationInfo.relationType && r.id === relationInfo.id && r.version === relationInfo.version)) {
                db.menuFormRelationInfo[routeName].relationInfos.push(relationInfo)
            } 
        });
    }
}

/**
 * 遞歸獲取表單或視圖關聯表單視圖版本資訊
 * @param {guid} objId 表單或視圖Id
 * @param {int} relationType 1=表單,2=視圖
 * @param {obj} relationInfos 表單和視圖版本資訊
 */
const findRelationConfigs = async function (objId, relationType, relationInfos) {
    if (!relationInfos) {
        relationInfos = []
    }
    console.log(relationType)
    var findData = relationType === 1 ? await db.form.get(objId) : await db.view.get(objId)
    if (findData && relationInfos.findIndex(r => r.id === findData.id) < 0) {
        relationInfos.push({ relationType: relationType, id: findData.id, version: findData.version })
    }
    if (findData && findData.relationInfos && findData.relationInfos.length > 0) {
        for (var i = 0; i < findData.relationInfos.length; i++) {
            await findRelationConfigs(findData.relationInfos[i].id, findData.relationInfos[i].relationType, relationInfos)
        }
    }
    console.log('relationInfos')
    console.log(relationInfos)
    return relationInfos
}

db.getFormData = async function (routeName, formId, fromMenu, applicationCode) {
    var formData = await db.form.get(formId)
    var dictFrameworkCache = await db.frameworkCache.get('dict')
    console.log("getFormData")
    if (!formData) {
        var resultData = await SpriteRumtimeApi.simpleform({ id: formId, applicationCode: applicationCode })
        var menuFormrelationInfos = []
        if (resultData && resultData) {
            for (var i = 0; i < resultData.formDatas.length; i++) {
                await db.form.put(resultData.formDatas[i])
                menuFormrelationInfos.push({relationType: 1, id: resultData.formDatas[i].id, version: resultData.formDatas[i].version})
            }
            for (var j = 0; j < resultData.viewDatas.length; j++) {
                await db.view.put(resultData.viewDatas[j])
                menuFormrelationInfos.push({relationType: 2, id: resultData.viewDatas[j].id, version: resultData.viewDatas[j].version})
            }
        }
        if (resultData && resultData.dictVersion && resultData.dicts) {
            await db.frameworkCache.put({ id: 'dict', version: resultData.dictVersion })
            await db.dict.clear()
            await db.dict.bulkAdd(resultData.dicts)
        }
        createMenuFormRelations(routeName, applicationCode, menuFormrelationInfos)
        formData = await db.form.get(formId)
    } else { // 從indexdb找到數據,如果從菜單進入,需要調用介面,判斷版本號資訊
        if (fromMenu) {
            delete db.menuFormRelationInfo[routeName]
            var relationInfos = await findRelationConfigs(formId, 1, [])
            var relationParams = { applicationCode: applicationCode, formId: formId, relationInfos: relationInfos, dictVersion: dictFrameworkCache?.version }
            var checkResult = await SpriteRumtimeApi.checkversions(relationParams)
            if ((checkResult && checkResult.formDatas && checkResult.formDatas.length > 0) || (checkResult && checkResult.viewDatas && checkResult.viewDatas.length > 0)) {
                relationInfos = []
            }
            if (checkResult && checkResult.formDatas && checkResult.formDatas.length > 0) {
                for (var i2 = 0; i2 < checkResult.formDatas.length; i2++) {
                    await db.form.put(checkResult.formDatas[i2])
                    relationInfos.push({relationType: 1, id: checkResult.formDatas[i2].id, version: checkResult.formDatas[i2].version})
                }
            }
            if (checkResult && checkResult.viewDatas && checkResult.viewDatas.length > 0) {
                for (var j2 = 0; j2 < checkResult.viewDatas.length; j2++) {
                    await db.view.put(checkResult.viewDatas[j2])
                    relationInfos.push({relationType: 2, id: checkResult.viewDatas[j2].id, version: checkResult.viewDatas[j2].version})
                }
            }
            if (checkResult && checkResult.dictVersion && checkResult.dicts) {
                await db.frameworkCache.put({ id: 'dict', version: checkResult.dictVersion })
                await db.dict.clear()
                await db.dict.bulkAdd(checkResult.dicts)
            }
            createMenuFormRelations(routeName, applicationCode, relationInfos)
            formData = await db.form.get(formId)
        }
    }
    return formData
}

 

開源地址://gitee.com/kuangqifu/sprite

體驗地址://47.108.141.193:8031(首次載入可能有點慢,用的阿里雲最差的伺服器)

自定義表單文章地址://www.cnblogs.com/spritekuang/

流程引擎文章地址://www.cnblogs.com/spritekuang/category/834975.html(採用WWF開發,已過時,已改用Elsa實現,//www.cnblogs.com/spritekuang/p/14970992.html )

Github地址://github.com/kuangqifu/CK.Sprite.Job