[PoC系列] 如何使用非同步更新網站快取(Cache)

如果我們網站的某些頁面流量比較大,而且會進行較大量的資料庫操作,或是使用CPU來做高度的運算,但可以接受使用者不一定需要看到最新的資料,並允許短暫時間的誤差 (例如: 五分鐘),這時候我們就可以考慮在這個頁面上加上Cache來減低Server的負擔。最常應用Cache的場景應該就是網站的首頁了,網站首頁通常會包含大量的資訊,也擁有最大量的瀏覽量,但可以容許資料十分鐘才更新一次,正是適合Cache的使用。今天我們也將舉一個簡單的範例來實作Cache,並逐步的調整使用的方法!

在網站系統中,我們經常會使用Cache來加快網站的回應速度,而如何讓Cache使用的效益最佳化也是一個很值得討論的議題,今天的文章主要是實作一些抽象的想法,並不一定可以直接套用在正式的網站之中,也歡迎大家一起分享自己的意見,未來也會撰寫一些類似系列的文章,來實作一些有趣的想法。

如果我們網站的某些頁面流量比較大,而且會進行較大量的資料庫操作,或是使用CPU來做高度的運算,但可以接受使用者不一定需要看到最新的資料,並允許短暫時間的誤差 (例如: 五分鐘),這時候我們就可以考慮在這個頁面上加上Cache來減低Server的負擔。最常應用Cache的場景應該就是網站的首頁了,網站首頁通常會包含大量的資訊,也擁有最大量的瀏覽量,但可以容許資料十分鐘才更新一次,正是適合Cache的使用。今天我們也將舉一個簡單的範例來實作Cache,並逐步的調整使用的方法!

模擬一個需要較長時間回應的頁面

首先我們實作一個頁面來呈現一個清單,但為了模擬長時間的運算,所以撰寫Service如下,在執行時會Delay5秒鐘才回傳資料


    public interface ILongRunningService
    {
        CacheDto> GetData(int min, int max);
    }

    public class LongRunningService : ILongRunningService
    {
        public CacheDto> GetData(int min, int max)
        {
            Thread.Sleep(5000);

            var r = new Random();
            var result = new List();

            for (int i = 0; i < 10; i++)
            {
                result.Add(r.Next(min, max));
            }

            return new CacheDto>
            {
                Data = result,
                UpdateTime = DateTime.Now
            };
        }
    }
/int>/ienumerable

CacheDto主要作為資料交換使用,並可以顯示資料被更新的時間


    public class CacheDto
    {
        public T Data { get; set; }

        public DateTime UpdateTime { get; set; }
    }

    public class CacheDto: CacheDto
    {
    }

將內容呈現在網頁上

你可以從Github tag: Basic website取得網站雛形

參考資料:

使用Redis做為網站的快取服務

Redis為一個KeyValue Pair類型的資料庫,也經常被使用作為Cache的場景,現在Azure之中也有提供Azure Redis Cache的服務,雖然目前是Preview版本,但我想應該不需要太久正式版就會出現在大家面前,本篇的範例也將使用Redis作為Cache的Server來儲存資料,接下來就向大家介紹如何將資料存放在Redis之中

  1. 安裝Redis服務,可以直接在Azure上申請,或是使用nuget下載

    
    Install-Package Redis-64
    

    使用Nuget下載後,直接執行redis-server.exe即可

  2. 安裝Redis Client,我們使用StackExchange.Redis作為Redis Client

    
    Install-Package StackExchange.Redis
    
  3. 在Autofac中註冊Redis的連線,並設定為Singleton (官方文件建議保留Connection,重複使用)

    
    builder.Register(i =>
    {
        var connect = ConnectionMultiplexer.Connect("localhost");
    
        return connect;
    }).AsSelf()
    .SingleInstance();      
    
  4. 新增一個StackExchangeRedisExtension的Class,擴充IDatabase介面,為設定和取得提供泛型及自動序列化的方法 (參考自MSDN)

    
    public static class StackExchangeRedisExtension
    {
        public static T Get(this IDatabase cache, string key)
        {
            return Deserialize(cache.StringGet(key));
        }
    
        public static object Get(this IDatabase cache, string key)
        {
            return Deserialize(cache.StringGet(key));
        }
    
        public static void Set(this IDatabase cache, string key, object value, TimeSpan? expire = null)
        {
            cache.StringSet(key, Serialize(value), expire);
        }
    
        static byte[] Serialize(object o)
        {
            if (o == null)
            {
                return null;
            }
    
            BinaryFormatter binaryFormatter = new BinaryFormatter();
            using (MemoryStream memoryStream = new MemoryStream())
            {
                binaryFormatter.Serialize(memoryStream, o);
                byte[] objectDataAsStream = memoryStream.ToArray();
                return objectDataAsStream;
            }
        }
    
        static T Deserialize(byte[] stream)
        {
            if (stream == null)
            {
                return default(T);
            }
    
            BinaryFormatter binaryFormatter = new BinaryFormatter();
            using (MemoryStream memoryStream = new MemoryStream(stream))
            {
                T result = (T)binaryFormatter.Deserialize(memoryStream);
                return result;
            }
        }
    }
    
  5. 改寫LongRunningService,改為透過Cache取得資料,若沒有資料才直接產生,並寫入Cache中

    
    public class LongRunningService : ILongRunningService
    {
        public ConnectionMultiplexer RedisConnection { get; set; }
    
        public LongRunningService(ConnectionMultiplexer connection)
        {
            this.RedisConnection = connection;
        }
    
        public CacheDto> GetData(int min, int max)
        {
            var cache = this.RedisConnection.GetDatabase();
    
            var key = string.Format("LongRunningService.GetData.{0}.{1}", min, max);
            var cachedData = cache.Get>>(key);
            if (cachedData == null)
            {
                Thread.Sleep(5000);
    
                var r = new Random();
                var result = new List();
    
                for (int i = 0; i < 10; i++)
                {
                    result.Add(r.Next(min, max));
                }
    
                cachedData = new CacheDto>
                {
                    Data = result,
                    UpdateTime = DateTime.Now
                };
    
                cache.Set(key, cachedData, TimeSpan.FromSeconds(30));
            }
    
            return cachedData;
        }
    }
    
  6. 記得將CacheDto加上Serializable的Attribute,標記為可序列化

    
    [Serializable]
    public class CacheDto
    {
        public T Data { get; set; }
    
        public DateTime UpdateTime { get; set; }
    }
    
  7. 重新整理頁面,可以發現在30秒內所取得的資料都會一模一樣,更新時間也相同,30秒之後才會更新,代表我們的Cache成功生效了!完成的程式碼請參考Github Branch: Integrate with redis

參考資料:

使用AOP,讓程式碼更加的乾淨!

在上一個段落中,我們整合了Redis作為我們網站的Cache,避免每次都需要重新產生資料,也減低了Server的負擔,但我們發現如果直接在程式碼每一段需要的地方都加上Cache的程式碼的話,不僅僅讓每一個Class的商業邏輯都變複雜,在測試上也不容易進行,因此我們可以透過Aop的方法來重構,撰寫通用的Inteceptor來實作Cache的機制,讓Cache的程式碼獨立在商業邏輯之外,只需要再要套用的Function上加上Attribute即可!

  1. 安裝Autofac.Extras.DynamicProxy2,支援Autofac的Aop套件

    
    Install-Package Autofac.Extras.DynamicProxy2
    
  2. 建立CacheAttribute,並可指定Cache持續時間 (單位: 秒)

    由於Autofac的Aop只能針對Class指定,但我們不一定這個Class的每個Method都希望被Cache,所以我們額外撰寫一個Attribute加在Method上,用來判斷這個Method是否需啟用快取,有加的才啟用。

    
    [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
    sealed class CacheAttribute : Attribute
    {
        public CacheAttribute()
        {
            this.ExpireTime = 20 * 60;
        }
    
        public string CacheKey { get; set; }
    
        public int ExpireTime { get; set; }
    }
    
  3. 建立CacheInterceptor,用來共用的Cache邏輯

    
    public class CacheInterceptor : IInterceptor
    {
        public ConnectionMultiplexer RedisConnection { get; set; }
    
        public CacheInterceptor(ConnectionMultiplexer connection)
        {
            this.RedisConnection = connection;
        }
    
        public void Intercept(IInvocation invocation)
        {
            //// 判斷目前方法是否有需要啟用Cache,若有加上標記表示需要
            var attributes = invocation.MethodInvocationTarget.GetCustomAttributes(typeof(CacheAttribute), true);
            if (attributes.Count() > 0)
            {
                //// Cache key
                var key = string.Format("{0}.{1}.{2}", invocation.TargetType.FullName,
                                                       invocation.MethodInvocationTarget.Name,
                                                       JsonConvert.SerializeObject(invocation.Arguments));
    
                //// Expire Time
                var expireTime = (attributes.First() as CacheAttribute).ExpireTime;
    
                IDatabase cache = this.RedisConnection.GetDatabase();
    
                //// Cache是否存在,不存在產生新的
                var result = cache.Get(key);
                if (result != null)
                {
                    invocation.ReturnValue = result;
                    return;
                }
    
                invocation.Proceed();                
    
                cache.Set(key, invocation.ReturnValue, TimeSpan.FromSeconds(expireTime));
            }
        }
    }
    
  4. 在LongRunningService上,加上Cache的Attribute

    
    [Intercept(typeof(CacheInterceptor))]
    public class LongRunningService : ILongRunningService
    {
        [Cache(ExpireTime = 30)]
        public CacheDto> GetData(int min, int max)
        {
            Thread.Sleep(5000);
    
            var r = new Random();
            var result = new List();
    
            for (int i = 0; i < 10; i++)
            {
                result.Add(r.Next(min, max));
            }
    
            return new CacheDto>
            {
                Data = result,
                UpdateTime = DateTime.Now
            };
        }
    }
    	
  5. 在Autofac的註冊中,標記LongRunningService需要注入攔截器,並且註冊CacheInterceptor

    
    builder.RegisterType()
           .As()
           .EnableInterfaceInterceptors();
    
    builder.RegisterType()
           .AsSelf();       
    
  6. 重新執行網頁,可以看到網頁按照我們預期的,每30秒更新一次資料,完成的程式碼請參考Github Branch: Integrate with redis and aop

參考資料:

使用HangFire,將更新Cache的工作交給背景來執行

在上面的段落中,基本上已經完成了一個簡單的Cache機制雛形,但有沒有可能再更進一步呢? 如果我們可以不要在前端的程式中更新頁面,而是當Cache過期時,新增一個背景工作,交給background的Queue System來更新Cache,這麼一來,我們就可以保持前端的頁面永遠是非常快地回應給Client!接下來我就要向大家介紹如何使用HangFire這套Framework來執行背景的工作。

  1. HangFire是一套Queue Framework,可以讓我們在背景執行指定的工作,而且非常簡單就能套用,我們先來進行HangFire的安裝

    
    Install-Package HangFire
    Install-Package HangFire.Redis
    Install-Package HangFire.Autofac
    
  2. 修改自動產生HangFireConfig檔案,使用Redis作為儲存空間

    
    public class HangFireConfig
    {
        private static AspNetBackgroundJobServer _server;
    
        public static void Start()
        {                
             JobStorage.Current = new RedisStorage("localhost:6379", 3);                              
    
            _server = new AspNetBackgroundJobServer();
            _server.Start();
        }
    
        public static void Stop()
        {
            _server.Stop();
        }
    }
    
  3. 連到http://localhost:1234/hangfire.axd,可以看到管理介面就代表安裝成功

  4. 新增一個RenewCacheAdapter,讓我們可以在背景中更新Cache,這邊主要是透過反射的機制,來動態執行撈取資料的Class取得資料並更新Cache

    
    public class RenewCacheAdapter
    {
        public ILifetimeScope LifetimeScope { get; set; }
    
        public ConnectionMultiplexer RedisConnection { get; set; }
    
        public RenewCacheAdapter(ILifetimeScope lifetimeScope, ConnectionMultiplexer connection)
        {
            this.LifetimeScope = lifetimeScope;
            this.RedisConnection = connection;
        }
    
        public void RenewCache(string key, int expireTime, string typeName, string methodName, string argumentString)
        {
            IDatabase cache = this.RedisConnection.GetDatabase();
    
            //// 若未過期,不執行更新
            var result = cache.Get(key);
            if (result.UpdateTime + TimeSpan.FromSeconds(expireTime) > DateTime.Now)
            {
                return;
            }
    
            //// 取得資料來源Class
            var targetType = Type.GetType(typeName);
            var methodInfo = targetType.GetMethod(methodName);
    
            var target = this.LifetimeScope.Resolve(targetType);
    
            //// 將參數轉換回原本的Type (因Json.Net會預設反序列化為Int64,但此處為Int32)
            var arguments = JsonConvert.DeserializeObject(argumentString);
            var parameterInfos = methodInfo.GetParameters();
            var changeTypesArguments = new List();
            for (int i = 0; i < parameterInfos.Length; i++)
            {
                var argument = arguments[i];
                var parameterInfo = parameterInfos[i];
    
                var changedArgument = Convert.ChangeType(argument, parameterInfo.ParameterType);
    
                changeTypesArguments.Add(changedArgument);
            }
    
            //// 執行方法,取得更新後的資料
            var returnValue = methodInfo.Invoke(target, changeTypesArguments.ToArray());
    
            result = new CacheDto
            {
                Data = returnValue,
                UpdateTime = DateTime.Now
            };
    
            //// 最長Cache時間,一小時 (避免Hangfire過於忙碌時,資料都沒有更新)
            cache.Set(key, result, TimeSpan.FromHours(1));
        }
    }
  5. 修改我們原本的CacheInterceptor,當Cache過期時,用寫入一個HangFire的更新Cache Job來取代直接更新Cache

    
    public class CacheInterceptor : IInterceptor
    {
        public ConnectionMultiplexer RedisConnection { get; set; }
    
        public CacheInterceptor(ConnectionMultiplexer connection)
        {
            this.RedisConnection = connection;
        }
    
        public void Intercept(IInvocation invocation)
        {
            //// 判斷目前方法是否有需要啟用Cache,若有加上標記表示需要
            var attributes = invocation.MethodInvocationTarget.GetCustomAttributes(typeof(CacheAttribute), true);
            if (attributes.Count() > 0)
            {
                //// Cache key
                var key = string.Format("{0}.{1}.{2}", invocation.TargetType.FullName,
                                                       invocation.MethodInvocationTarget.Name,
                                                       JsonConvert.SerializeObject(invocation.Arguments));
    
                //// Expire Time
                var expireTime = (attributes.First() as CacheAttribute).ExpireTime;
    
                IDatabase cache = this.RedisConnection.GetDatabase();
    
                //// 完全無資料時,直接產生
                var result = cache.Get(key);
                if (result == null)
                {
                    invocation.Proceed();
    
                    var dataToCache = new CacheDto{
                        Data = invocation.ReturnValue,
                        UpdateTime = DateTime.Now
                    };
    
                    cache.Set(key, dataToCache, TimeSpan.FromHours(1));
    
                    return;
                }
    
                //// 快取過期時,產生一個背景更新的工作
                //// 因背景後續才執行,因此此處可以馬上回應
                if (result.UpdateTime + TimeSpan.FromSeconds(expireTime) < DateTime.Now)
                {
                    BackgroundJob.Enqueue(
                        i => i.RenewCache(key,
                                          expireTime,
                                          invocation.TargetType.FullName,
                                          invocation.MethodInvocationTarget.Name,
                                          JsonConvert.SerializeObject(invocation.Arguments)));
                }
    
                //// 直接回傳舊資料
                invocation.ReturnValue = result.Data;
            }
        }
    }
    
  6. 最後在AutofacConfig中註冊所需要的Class

    
    //// For website
    builder.RegisterType()
           .As()
           .EnableInterfaceInterceptors();
    
    //// For background job
    builder.RegisterType()
           .AsSelf();
    
    builder.RegisterType()
           .AsSelf();
    
    builder.RegisterType()
           .AsSelf();
    
  7. 重新執行程式,我們可以發現不論是否更新資料,每次的回應都相當快速,而在背景中也可以看到有任務被寫入,並使用HangFire執行進行Cache的更新

完成的程式碼請參考Github Branch: Integrate with hangfire

參考資料:

總結

本篇文章只是一個概念的雛型,若要真正實際應用到網站之中還有許多可以完善調整的地方,但可藉此來對一些想法做初步的驗證,也是非常的有趣,未來有機會還會分享更多類似的內容,希望可以和大家一起腦力激盪來討論一些有趣架構的實作,也歡迎大家分享自己的想法喔 ^_^