ASP.NET Core 所使用的 Cache - MemoryCache 篇

  • 6176
  • 0

ASP.NET Core 所使用的 Cache 方式有好幾種,有分成集中式和分散式,使用的儲存空間也有電腦記憶體, Redis, SQL Server 三種.這篇文件介紹的 MemoryCache 便屬於集中式並且使用電腦記憶體為儲存空間. 

MemoryCache 故名思義就知道這是以 memory 為儲存區的快取.簡單地說,你將想要做為快取的物件放入到 memory 裡面,同時讀取時也是從 memory 裡面讀取出來.這樣子的做法優點在於快速,因為 CPU 存取 memory 是很快速的,然而缺點就是只能在同一台機器上.為什麼在同一台機器上會是缺點呢 ? 這取決於你的執行環境而定.比如,你的環境裡只有一台 web server,所有的 http request 都將進入到同一台的 web server,所以你將快取資料放在這台機器的 memory,並不會有什麼問題產生.如果流量過大,採用了 load balance 的設計,則 web server 可能會有兩台以上,也就是一般俗稱的 web server farm,由於 web server farm 的組態設定可以允許來自同一台用戶端的 http request 連線到  web server farm 裡不同的機器上,這將造成你把快取資料寫在 A 機器上的 memory,結果這個用戶端下一個發出的 http request 被導向到 B 機器去了,這樣就無法使用到剛剛被存在 A 機器上的快取資料.所以,這就是前面所提的缺點只能在同一台機器上.因此,若你採用 MemoryCache 而且你的環境是多台 web server 組成時,請記得在 web server farm 裡把來自同一個用戶端的 http request 導向到一樣的伺服器上,這樣才能讓後面的 http request 用到前面 http request 所儲存下來的快取資料.

MemoryCache 的 namespace 是定義在 Microsoft.Extensions.Caching.Memory.它的 namespace 空間並不是定義在 ASP.NET Core 之下,也就表示你不一定非得在 ASP.NET Core 的專案下才能使用它,在其他的專案下也是可以使用的,如 WPF, Console 等等.這點跟之前講的 Configuration Logging 是具有一樣的特點.在這個 namespace 之下的定義都是 concrete implementation,相關的重要 interface 都定義在 Microsoft.Extensions.Caching.Abstractions 裡面,其中有兩個 interface 是最重要的,一個是 IMemoryCache,另一個是 ICacheEntry.

基本 interface 定義

IMemoryCache 是用來定義 MemoryCache 的實體物件該具有什麼樣子的動作,以下是 IMemoryCache 的定義 :

   
    /// <summary>
    /// Represents a local in-memory cache whose values are not serialized.
    /// </summary>
    public interface IMemoryCache : IDisposable
    {
        /// <summary>
        /// Gets the item associated with this key if present.
        /// </summary>
        /// <param name="key">An object identifying the requested entry.</param>
        /// <param name="value">The located value or null.</param>
        /// <returns>True if the key was found.</returns>
        bool TryGetValue(object key, out object value);

        /// <summary>
        /// Create or overwrite an entry in the cache.
        /// </summary>
        /// <param name="key">An object identifying the entry.</param>
        /// <returns>The newly created <see cref="ICacheEntry"/> instance.</returns>
        ICacheEntry CreateEntry(object key);

        /// <summary>
        /// Removes the object associated with the given key.
        /// </summary>
        /// <param name="key">An object identifying the entry.</param>
        void Remove(object key);
    }

source: https://github.com/aspnet/Caching/blob/rel/1.1.1/src/Microsoft.Extensions.Caching.Abstractions/IMemoryCache.cs

從這份 interface 定義來看,我們就知道實做此 interface 的物件已經具有基本的動作,分別是從 memory 取出快取資料,建立快取資料以及刪除快取資料.

ICacheEntry 是用來定義快取資料本身該有的屬性與動作 :

    /// <summary>
    /// Represents an entry in the <see cref="IMemoryCache"/> implementation.
    /// </summary>
    public interface ICacheEntry : IDisposable
    {
        /// <summary>
        /// Gets the key of the cache entry.
        /// </summary>
        object Key { get; }

        /// <summary>
        /// Gets or set the value of the cache entry.
        /// </summary>
        object Value { get; set; }

        /// <summary>
        /// Gets or sets an absolute expiration date for the cache entry.
        /// </summary>
        DateTimeOffset? AbsoluteExpiration { get; set; }
        
        /// <summary>
        /// Gets or sets an absolute expiration time, relative to now.
        /// </summary>
        TimeSpan? AbsoluteExpirationRelativeToNow { get; set; }

        /// <summary>
        /// Gets or sets how long a cache entry can be inactive (e.g. not accessed) before it will be removed.
        /// This will not extend the entry lifetime beyond the absolute expiration (if set).
        /// </summary>
        TimeSpan? SlidingExpiration { get; set; }

        /// <summary>
        /// Gets the <see cref="IChangeToken"/> instances which cause the cache entry to expire.
        /// </summary>
        IList<IChangeToken> ExpirationTokens { get; }

        /// <summary>
        /// Gets or sets the callbacks will be fired after the cache entry is evicted from the cache.
        /// </summary>
        IList<PostEvictionCallbackRegistration> PostEvictionCallbacks { get; }

        /// <summary>
        /// Gets or sets the priority for keeping the cache entry in the cache during a
        /// memory pressure triggered cleanup. The default is <see cref="CacheItemPriority.Normal"/>.
        /// </summary>
        CacheItemPriority Priority { get; set; }
    }

source: https://github.com/aspnet/Caching/blob/rel/1.1.1/src/Microsoft.Extensions.Caching.Abstractions/ICacheEntry.cs

從上面的 interface 可以看的出來,除了 key, value 以外,還可以有幾種不同的方式來設定資料過期,並且還可以設定當資料過期被清除時就引發的 callback,以及設定快取資料的 priority.為什麼快取資料會有 priority 呢 ? 快取資料被存取的順序並沒有誰先誰後,快取資料都是放在同一個 Dictionary 裡面,所以只要你用正確的 key 就能得到 value,並沒有誰比較優先的問題.這裡的 Priority 是指當 MemoryCache 需要被 "壓縮" 時,priority 低的就比較容易會擠出去,也就是刪除.這裡所謂的壓縮不是指資料壓縮,而是指當 MemoryCache 佔用的記憶體空間過多時,你想把 MemoryCache 縮小進而釋放出記憶體空間.所以當 MemoryCache 的空間要縮小時,這就表示有快取資料會被剔除,而 Priority 就是一種剔除的參考條件.依照目前 1.1.1 版的定義,Priority 有 Low, Normal, High, NeverRemove 這 4 種.

實做這兩個 interface 的 concrete implementation 是在 Microsoft.Extensions.Caching.Memory 裡的 MemoryCache CacheEntry classes. 從 MemoryCache.cs 中你可以看到快取資料是放在 ConcurrentDictionary 裡面,利用它就不用擔心同一時間內同一份資料被讀取或刪除的問題,因為 ConcurrentDictionary 會幫忙處理這情況.

寫入與讀取快取資料

接下來用簡單的範例來說明如何建立一個 MemoryCache 然後將快取資料寫入與讀出.

var cache = new MemoryCache(new MemoryCacheOptions());
var obj = new object();
string key = "myKey";
// 寫入快取資料
cache.Set(key, obj);  

// 讀出快取資料
var obj2 = cache.Get(key);

// obj2 和 obj 是同一個物件

使用上非常的容易.你的程式只要建立一份 MemoryCache instance,把它放在 dependency injection container 或是透過其它的方式讓你程式裡需要的地方都可以存取到這份 MemoryCache instance,這樣就能使用了.在建立時,你可以看到需要帶入 MemoryCacheOptions,以上的範例全部是依預設的參數來建立,預設的參數包含了系統時間以及檢查快取資料是否過期的時間間隔 (預設是一分鐘檢查一次).這裡特別說明,MemoryCache 的 key 和 value 都是用 object type 來存入,所以 key = "myKey" 與 "MyKey" 是不一樣的 key.如果你用相同的 key 重覆寫入,則舊的快取資料將被覆寫.這是可以預期的,因為跟 Dictionary 的動作一樣.這時你可能會發現上面範例 MemoryCache 用的 Set() , Get() 並沒有定義在 IMemoryCache 裡,實際上他們是 IMemoryCache 的 extension method,定義在 MemoryCacheExtensions.cs 裡.若你用 CreateEntry() , TryGetValue() 來寫入與讀取快取資料,也是可以達到一樣的目的.除了同步的存取方式以外,在 MemoryCacheExtensions 裡也提供了非同步的存取方式可用,但我覺得非同步的存取方式並不是那麼需要,畢竟這裡的資料是放在記憶體,存取速度超級快.

快取資料選項 MemoryCacheEntryOptions

快取資料有些選項可供設定,例如過期時間,被刪除時的 callback 與 cache priority 等等.預設上,快取資料的選項裡沒有設定固定的過期時間,所以快取資料一直存在直到被刪除或覆寫.預設上也沒有設定任何的 callback 並且所有資料的 priority 都是 Normal.

以下是一個簡單例子用來說明某個快取資料選項將過期時間設定為今年的最後一天

            var time = new DateTimeOffset(2017, 12, 31, 23, 59, 59, TimeSpan.Zero);
            var cacheEntryOption = new MemoryCacheEntryOptions().SetAbsoluteExpiration(time));

在選項裡還有一個比較特別的東西叫 ExpirationToken.這是一個讓你在使用該份快取資料時,一旦在使用的過程中發現這份快取資料不應該繼續存活下去了,則直接將這個 Token 上的 HasChanged 屬性設定為 true 即可.ExpirationToken 在現有的原始碼中並沒有任何實做,只定義了 IChangeToken interface,Token 物件都必需實做這份 interface.若你需要製做這方面的功能,可以參考 unit test 中 TestExpirationToken 的實做.

刪除快取資料後的 Callbacks (PostEvictionCallbacks)

每一筆快取資料在寫入的時候可以設定一些選項.這些選項將能讓你為每個快取資料做些特別的定義.例如,某一類的快取資料被剔除時,你可以指定做某一個動作,而另一類的快取資料被剔除時,你可以指定做其他的動作.用一個簡單的例子來說明.

            var cache = new MemoryCache(new MemoryCacheOptions());
            var value1 = new object();
            string key = "myKey";
            var callback1Invoked = new ManualResetEvent(false);
            var callback2Invoked = new ManualResetEvent(false);

            var options1 = new MemoryCacheEntryOptions();
            options1.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration()
            {
                EvictionCallback = (subkey, subValue, reason, state) =>
                {
                    // 這裡執行某一個動作
                    // ....
                    var localCallbackInvoked = (ManualResetEvent)state;
                    localCallbackInvoked.Set();
                },
                State = callback1Invoked
            });

            var result = cache.Set(key, value1, options1);

            var value2 = new object();
            var options2 = new MemoryCacheEntryOptions();
            options2.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration()
            {
                EvictionCallback = (subkey, subValue, reason, state) =>
                {
                    // 這裡執行另一個動作
                    // ....
                    var localCallbackInvoked = (ManualResetEvent)state;
                    localCallbackInvoked.Set();
                },
                State = callback2Invoked
            });
            result = cache.Set(key, value2, options2);

上述的程式碼,宣告了一個 MemoryCacheEntryOptions 用來描述快取資料的選項,其中刪除後的動作可以透過 PostEvictionCallbacks 來設定 PostEvictionCallBackRegistration 物件,它的第一個需要的參數就是你要執行動作的 delegate,你可以用一個 lamda 方式來撰寫較為方便,接著便將第一筆快取資料寫入到 MemoryCache 中.然後也設定了第二個快取資料,同樣地也設定了其他的 PostEvictionCallbacks 資訊.第二筆快取資料也使用相同的 key 寫入到 MemoryCache 中,這就造成第一筆快取資料會被刪除,因此之前設定在第一筆快取資料選項中的 delegate 就會被執行.

在 ASP.NET Core 上使用 MemoryCache

在 ASP.NET Core 上使用 MemoryCache 相當簡單.首先從 NuGet 上下載安裝 MemoryCache package,然後再將 MemoryCache 增到到 ASP.NET Core DI 裡,接著在 Controller 裡再將它取出來使用即可.

下面為新增到 DI 的範例

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMemoryCache();
        services.AddMvc();
    }

// 以下省略...

下面為在 Controller 裡將 MemoryCache 物件取出來使用

public class HomeController : Controller
{
    private IMemoryCache _cache;

    public HomeController(IMemoryCache memoryCache)
    {
        _cache = memoryCache;
    }

// 以下省略...

接著你就可以在 Controller 裡的 methods 中去寫入或讀取快取資料.

Hope it helps,